A page may not ask freeq what a link is
Link previews shipped asking freeq directly from the browser, and every fetch was refused before it left: freeq answers `access-control-allow-origin` for five origins it knows — its own, the broker's, and vite's two — and this bundle is served from none of them. The request went out, the server answered, and the browser withheld it. On the screen that read as a feature that did nothing at all, which is the failure this change is really about: the client handled the failure correctly and so said nothing about it. freeq's own web client has no such problem because it is served *by* freeq and asks for a relative path. That is the whole difference, and it is the one `/api/v1/upload` already ran into — so this takes the answer already written next to it. `webserve.py` relays `/api/v1/og` as well, the page asks its own server for its own path, and there is no preflight, no allowlist and no CORS to be got around. So `ogEndpoint` splits in two. `ogPath` is the path alone, which is what a page wants; `ogEndpoint` is that on a named host, which is what the desktop wants — it makes the request itself, where the rule does not apply. The host seam takes the difference, as it does for the socket and the store: the shared half asks for a preview and never learns who was asked. The relay forwards nothing of the caller's and passes freeq's own refusals back with their reasons — "Body too large" for a page over its 256KB cap is a sentence worth keeping, and a 502 of our own in its place would lose it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
391922b parent: 050629b modified
nim/src/frq/links.nim +18 -5 | @@ -77,8 +77,24 @@ func domainOf*(url: string): string = | ||
| 77 | 77 | rest = rest.toLowerAscii |
| 78 | 78 | if rest.startsWith("www."): rest[4 .. ^1] else: rest |
| 79 | 79 | |
| 80 | +func ogPath*(url: string): string = | |
| 81 | + ## freeq's preview proxy as a path, without a host on the front. | |
| 82 | + ## | |
| 83 | + ## Two callers want different halves of the same thing. The desktop asks | |
| 84 | + ## the server directly and needs the whole URL; the page may not — freeq | |
| 85 | + ## answers `access-control-allow-origin` for its own five origins and this | |
| 86 | + ## bundle is served from none of them — so it asks its own server for this | |
| 87 | + ## path and that server relays. Same reason `/api/v1/upload` is relayed, | |
| 88 | + ## and the same fixed upstream: `tools/webserve.py` says the rest. | |
| 89 | + ## | |
| 90 | + ## `usePlus = false`: a space comes out as %20 rather than as `+`, which is | |
| 91 | + ## form encoding and would make a literal plus — a real character in a path | |
| 92 | + ## — have to be escaped to survive. %20 is right for both. | |
| 93 | + "/api/v1/og?url=" & encodeUrl(url, usePlus = false) | |
| 94 | + | |
| 80 | 95 | func ogEndpoint*(host, url: string): string = |
| 81 | - ## freeq's preview proxy, on the server this client is connected to. | |
| 96 | + ## That path on the server this client is connected to, for a host that may | |
| 97 | + ## make the request itself. | |
| 82 | 98 | ## |
| 83 | 99 | ## Port 6697 is the IRC socket and has nothing to do with this; the REST |
| 84 | 100 | ## side is plain HTTPS, so whatever port the form carries is dropped. |
| @@ -86,10 +102,7 @@ func ogEndpoint*(host, url: string): string = | ||
| 86 | 102 | let colon = h.find(':') |
| 87 | 103 | if colon >= 0: h = h[0 ..< colon] |
| 88 | 104 | if h.len == 0: return "" |
| 89 | - # `usePlus = false`: a space comes out as %20 rather than as `+`, which is | |
| 90 | - # form encoding and means a literal plus in a path — a real one in a URL — | |
| 91 | - # would have to be escaped to survive. %20 is right for both. | |
| 92 | - "https://" & h & "/api/v1/og?url=" & encodeUrl(url, usePlus = false) | |
| 105 | + "https://" & h & ogPath(url) | |
| 93 | 106 | |
| 94 | 107 | func parsePreview*(body: JsonNode): Preview = |
| 95 | 108 | ## The card's fields out of an `/api/v1/og` answer. Every one of them is |
| @@ -77,8 +77,24 @@ func domainOf*(url: string): string = | |||
| 77 | rest = rest.toLowerAscii | 77 | rest = rest.toLowerAscii |
| 78 | if rest.startsWith("www."): rest[4 .. ^1] else: rest | 78 | if rest.startsWith("www."): rest[4 .. ^1] else: rest |
| 79 | 79 | ||
| 80 | +func ogPath*(url: string): string = | ||
| 81 | + ## freeq's preview proxy as a path, without a host on the front. | ||
| 82 | + ## | ||
| 83 | + ## Two callers want different halves of the same thing. The desktop asks | ||
| 84 | + ## the server directly and needs the whole URL; the page may not — freeq | ||
| 85 | + ## answers `access-control-allow-origin` for its own five origins and this | ||
| 86 | + ## bundle is served from none of them — so it asks its own server for this | ||
| 87 | + ## path and that server relays. Same reason `/api/v1/upload` is relayed, | ||
| 88 | + ## and the same fixed upstream: `tools/webserve.py` says the rest. | ||
| 89 | + ## | ||
| 90 | + ## `usePlus = false`: a space comes out as %20 rather than as `+`, which is | ||
| 91 | + ## form encoding and would make a literal plus — a real character in a path | ||
| 92 | + ## — have to be escaped to survive. %20 is right for both. | ||
| 93 | + "/api/v1/og?url=" & encodeUrl(url, usePlus = false) | ||
| 94 | + | ||
| 80 | func ogEndpoint*(host, url: string): string = | 95 | func ogEndpoint*(host, url: string): string = |
| 81 | - ## freeq's preview proxy, on the server this client is connected to. | 96 | + ## That path on the server this client is connected to, for a host that may |
| 97 | + ## make the request itself. | ||
| 82 | ## | 98 | ## |
| 83 | ## Port 6697 is the IRC socket and has nothing to do with this; the REST | 99 | ## Port 6697 is the IRC socket and has nothing to do with this; the REST |
| 84 | ## side is plain HTTPS, so whatever port the form carries is dropped. | 100 | ## side is plain HTTPS, so whatever port the form carries is dropped. |
| @@ -86,10 +102,7 @@ func ogEndpoint*(host, url: string): string = | |||
| 86 | let colon = h.find(':') | 102 | let colon = h.find(':') |
| 87 | if colon >= 0: h = h[0 ..< colon] | 103 | if colon >= 0: h = h[0 ..< colon] |
| 88 | if h.len == 0: return "" | 104 | if h.len == 0: return "" |
| 89 | - # `usePlus = false`: a space comes out as %20 rather than as `+`, which is | 105 | + "https://" & h & ogPath(url) |
| 90 | - # form encoding and means a literal plus in a path — a real one in a URL — | ||
| 91 | - # would have to be escaped to survive. %20 is right for both. | ||
| 92 | - "https://" & h & "/api/v1/og?url=" & encodeUrl(url, usePlus = false) | ||
| 93 | 106 | ||
| 94 | func parsePreview*(body: JsonNode): Preview = | 107 | func parsePreview*(body: JsonNode): Preview = |
| 95 | ## The card's fields out of an `/api/v1/og` answer. Every one of them is | 108 | ## The card's fields out of an `/api/v1/og` answer. Every one of them is |
modified
nim/tests/tlinks.nim +7 -0 | @@ -43,6 +43,13 @@ suite "domainOf": | ||
| 43 | 43 | check domainOf("http://example.com:8080/x") == "example.com" |
| 44 | 44 | check domainOf("https://user@example.com/x") == "example.com" |
| 45 | 45 | |
| 46 | +suite "ogPath": | |
| 47 | + test "a path, for a page that must ask its own server": | |
| 48 | + check ogPath("https://example.com/a b") == | |
| 49 | + "/api/v1/og?url=https%3A%2F%2Fexample.com%2Fa%20b" | |
| 50 | + test "a space is %20 and not a plus, so a literal plus survives": | |
| 51 | + check ogPath("https://e.com/a+b") == "/api/v1/og?url=https%3A%2F%2Fe.com%2Fa%2Bb" | |
| 52 | + | |
| 46 | 53 | suite "ogEndpoint": |
| 47 | 54 | test "freeq's proxy, on the server we are connected to": |
| 48 | 55 | check ogEndpoint("irc.freeq.at", "https://example.com/a b") == |
| @@ -43,6 +43,13 @@ suite "domainOf": | |||
| 43 | check domainOf("http://example.com:8080/x") == "example.com" | 43 | check domainOf("http://example.com:8080/x") == "example.com" |
| 44 | check domainOf("https://user@example.com/x") == "example.com" | 44 | check domainOf("https://user@example.com/x") == "example.com" |
| 45 | 45 | ||
| 46 | +suite "ogPath": | ||
| 47 | + test "a path, for a page that must ask its own server": | ||
| 48 | + check ogPath("https://example.com/a b") == | ||
| 49 | + "/api/v1/og?url=https%3A%2F%2Fexample.com%2Fa%20b" | ||
| 50 | + test "a space is %20 and not a plus, so a literal plus survives": | ||
| 51 | + check ogPath("https://e.com/a+b") == "/api/v1/og?url=https%3A%2F%2Fe.com%2Fa%2Bb" | ||
| 52 | + | ||
| 46 | suite "ogEndpoint": | 53 | suite "ogEndpoint": |
| 47 | test "freeq's proxy, on the server we are connected to": | 54 | test "freeq's proxy, on the server we are connected to": |
| 48 | check ogEndpoint("irc.freeq.at", "https://example.com/a b") == | 55 | check ogEndpoint("irc.freeq.at", "https://example.com/a b") == |
modified
nim/web/frq/linkfetch.nim +13 -2 | @@ -8,6 +8,14 @@ | ||
| 8 | 8 | ## Same endpoint, and here it is the only one that would work at all: a page |
| 9 | 9 | ## cannot read a third-party site's HTML, so `/api/v1/og` is both the private |
| 10 | 10 | ## answer and the possible one. |
| 11 | +## | |
| 12 | +## What differs is who is asked. The desktop asks freeq directly; a page may | |
| 13 | +## not. freeq answers `access-control-allow-origin` for five origins it | |
| 14 | +## knows and this bundle is served from none of them, so the fetch is made, | |
| 15 | +## answered, and the answer withheld — which is what happened the first time | |
| 16 | +## this shipped, and read on the screen as a feature that simply did not | |
| 17 | +## work. So the page asks its own server for its own path and that server | |
| 18 | +## relays, exactly as `/api/v1/upload` already does. `tools/webserve.py`. | |
| 11 | 19 | |
| 12 | 20 | import std/deques |
| 13 | 21 | import std/json |
| @@ -33,8 +41,11 @@ proc arrived(url, body: cstring) = | ||
| 33 | 41 | proc want*(host, url: string) = |
| 34 | 42 | ## Once per link for the run, as on the desktop. |
| 35 | 43 | if url.len == 0 or known(url) or not previewable(url): return |
| 36 | - let endpoint = ogEndpoint(host, url) | |
| 37 | - if endpoint.len == 0: return | |
| 44 | + # A path and not a URL: same-origin, so there is no preflight, no | |
| 45 | + # allowlist and no CORS at all. `host` is the freeq this client is talking | |
| 46 | + # to and is not used here — the relay's upstream is its own, set where it | |
| 47 | + # runs rather than by whatever the page asks for. | |
| 48 | + let endpoint = ogPath(url) | |
| 38 | 49 | remember(url, Preview(status: lsLoading)) |
| 39 | 50 | fetchPreview(endpoint.cstring, url.cstring, arrived) |
| 40 | 51 | |
| @@ -8,6 +8,14 @@ | |||
| 8 | ## Same endpoint, and here it is the only one that would work at all: a page | 8 | ## Same endpoint, and here it is the only one that would work at all: a page |
| 9 | ## cannot read a third-party site's HTML, so `/api/v1/og` is both the private | 9 | ## cannot read a third-party site's HTML, so `/api/v1/og` is both the private |
| 10 | ## answer and the possible one. | 10 | ## answer and the possible one. |
| 11 | +## | ||
| 12 | +## What differs is who is asked. The desktop asks freeq directly; a page may | ||
| 13 | +## not. freeq answers `access-control-allow-origin` for five origins it | ||
| 14 | +## knows and this bundle is served from none of them, so the fetch is made, | ||
| 15 | +## answered, and the answer withheld — which is what happened the first time | ||
| 16 | +## this shipped, and read on the screen as a feature that simply did not | ||
| 17 | +## work. So the page asks its own server for its own path and that server | ||
| 18 | +## relays, exactly as `/api/v1/upload` already does. `tools/webserve.py`. | ||
| 11 | 19 | ||
| 12 | import std/deques | 20 | import std/deques |
| 13 | import std/json | 21 | import std/json |
| @@ -33,8 +41,11 @@ proc arrived(url, body: cstring) = | |||
| 33 | proc want*(host, url: string) = | 41 | proc want*(host, url: string) = |
| 34 | ## Once per link for the run, as on the desktop. | 42 | ## Once per link for the run, as on the desktop. |
| 35 | if url.len == 0 or known(url) or not previewable(url): return | 43 | if url.len == 0 or known(url) or not previewable(url): return |
| 36 | - let endpoint = ogEndpoint(host, url) | 44 | + # A path and not a URL: same-origin, so there is no preflight, no |
| 37 | - if endpoint.len == 0: return | 45 | + # allowlist and no CORS at all. `host` is the freeq this client is talking |
| 46 | + # to and is not used here — the relay's upstream is its own, set where it | ||
| 47 | + # runs rather than by whatever the page asks for. | ||
| 48 | + let endpoint = ogPath(url) | ||
| 38 | remember(url, Preview(status: lsLoading)) | 49 | remember(url, Preview(status: lsLoading)) |
| 39 | fetchPreview(endpoint.cstring, url.cstring, arrived) | 50 | fetchPreview(endpoint.cstring, url.cstring, arrived) |
| 40 | 51 | ||
modified
tools/webserve.py +60 -9 | @@ -1,5 +1,5 @@ | ||
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | -"""Serve the web bundle, and relay the one request a browser may not make. | |
| 2 | +"""Serve the web bundle, and relay the two requests a browser may not make. | |
| 3 | 3 | |
| 4 | 4 | `python3 -m http.server` did this until the upload needed to work. freeq's |
| 5 | 5 | media endpoint answers `access-control-allow-origin` for exactly one origin |
| @@ -17,12 +17,21 @@ no CORS at all -- the browser is not being circumvented, it is being told | ||
| 17 | 17 | the truth. This process makes the cross-origin request, where the rule does |
| 18 | 18 | not apply, and hands back what came. |
| 19 | 19 | |
| 20 | -Two things this is deliberately not. It is not a general proxy: one path, | |
| 21 | -one method, one upstream, fixed here rather than taken from the request, so | |
| 22 | -it cannot be pointed at anything. And it is not a permanent answer -- one | |
| 23 | -`Access-Control-Allow-Origin: *` on a public, unauthenticated endpoint would | |
| 24 | -retire it, and that ask is now the small one because the page sends no | |
| 25 | -credentials. | |
| 20 | +`/api/v1/og` is here for the same reason and was found the same way: link | |
| 21 | +previews shipped asking freeq directly, every fetch was blocked by the | |
| 22 | +allowlist, and on the screen that read as a feature that simply did nothing. | |
| 23 | +The difference from the upload is only which way the bytes go -- a GET with | |
| 24 | +the page's `?url=` passed through, and the OpenGraph JSON handed back. | |
| 25 | + | |
| 26 | +Two things this is deliberately not. It is not a general proxy: two paths, | |
| 27 | +one method each, one upstream, fixed here rather than taken from the | |
| 28 | +request, so it cannot be pointed at anything. The `url` a caller passes is | |
| 29 | +freeq's business, not this server's -- it is the one that fetches it, and it | |
| 30 | +resolves the host and refuses the private ranges before it does. | |
| 31 | + | |
| 32 | +And it is not a permanent answer -- one `Access-Control-Allow-Origin: *` on | |
| 33 | +a public, unauthenticated endpoint would retire both, and that ask is now | |
| 34 | +the small one because the page sends no credentials. | |
| 26 | 35 | |
| 27 | 36 | python3 tools/webserve.py [PORT] [DIRECTORY] |
| 28 | 37 | |
| @@ -38,6 +47,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer | ||
| 38 | 47 | |
| 39 | 48 | UPSTREAM = os.environ.get("FRQ_API_ORIGIN", "https://irc.freeq.at").rstrip("/") |
| 40 | 49 | UPLOAD = "/api/v1/upload" |
| 50 | +OG = "/api/v1/og" | |
| 41 | 51 | |
| 42 | 52 | # The endpoint's own cap. Refused here rather than after twelve megabytes |
| 43 | 53 | # have crossed this machine on their way to being turned down. |
| @@ -79,10 +89,51 @@ class Handler(SimpleHTTPRequestHandler): | ||
| 79 | 89 | no body. A round trip per file, against a fix that does not arrive. |
| 80 | 90 | """ |
| 81 | 91 | path = self.path.split("?")[0] |
| 82 | - if not path.startswith(UPLOAD): | |
| 92 | + if path not in (UPLOAD, OG): | |
| 83 | 93 | self.send_header("Cache-Control", "no-cache") |
| 84 | 94 | SimpleHTTPRequestHandler.end_headers(self) |
| 85 | 95 | |
| 96 | + def do_GET(self): | |
| 97 | + """The bundle, or the preview relay. | |
| 98 | + | |
| 99 | + Everything but one path is a file, so the base class answers first | |
| 100 | + and this only steps in front of it. | |
| 101 | + """ | |
| 102 | + if self.path.split("?")[0] != OG: | |
| 103 | + SimpleHTTPRequestHandler.do_GET(self) | |
| 104 | + return | |
| 105 | + | |
| 106 | + query = self.path.split("?", 1)[1] if "?" in self.path else "" | |
| 107 | + if not query.startswith("url="): | |
| 108 | + self.send_error(400, "url is required") | |
| 109 | + return | |
| 110 | + | |
| 111 | + # Nothing of the caller's is forwarded -- no cookies, no | |
| 112 | + # Authorization, nothing this server was trusted with. The query is | |
| 113 | + # already encoded by whoever built it and is passed through as it | |
| 114 | + # came, since re-encoding it here would be a second opinion about | |
| 115 | + # what the URL was. | |
| 116 | + req = urllib.request.Request( | |
| 117 | + UPSTREAM + OG + "?" + query, method="GET", | |
| 118 | + ) | |
| 119 | + try: | |
| 120 | + with urllib.request.urlopen(req, timeout=30) as r: | |
| 121 | + status, body, kind = r.status, r.read(), r.headers.get_content_type() | |
| 122 | + except urllib.error.HTTPError as e: | |
| 123 | + # freeq's own refusal, passed through with its reason -- "Body | |
| 124 | + # too large" for a page over its cap, "Blocked" for a host it | |
| 125 | + # will not resolve. A 502 in place of those would lose the only | |
| 126 | + # sentence saying what happened. | |
| 127 | + status, body, kind = e.code, e.read(), "application/json" | |
| 128 | + except OSError as e: | |
| 129 | + status, body, kind = 502, str(e).encode(), "text/plain" | |
| 130 | + | |
| 131 | + self.send_response(status) | |
| 132 | + self.send_header("Content-Type", kind) | |
| 133 | + self.send_header("Content-Length", str(len(body))) | |
| 134 | + self.end_headers() | |
| 135 | + self.wfile.write(body) | |
| 136 | + | |
| 86 | 137 | def do_POST(self): |
| 87 | 138 | if self.path.split("?")[0] != UPLOAD: |
| 88 | 139 | self.send_error(404, "Not Found") |
| @@ -143,7 +194,7 @@ def main() -> None: | ||
| 143 | 194 | handler = partial(Handler, directory=directory) |
| 144 | 195 | srv = ThreadingHTTPServer(("", port), handler) |
| 145 | 196 | print(f"serving {directory} on http://localhost:{port}" |
| 146 | - f" ({UPLOAD} relays to {UPSTREAM})", flush=True) | |
| 197 | + f" ({UPLOAD} and {OG} relay to {UPSTREAM})", flush=True) | |
| 147 | 198 | srv.serve_forever() |
| 148 | 199 | |
| 149 | 200 | |
| @@ -1,5 +1,5 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | -"""Serve the web bundle, and relay the one request a browser may not make. | 2 | +"""Serve the web bundle, and relay the two requests a browser may not make. |
| 3 | 3 | ||
| 4 | `python3 -m http.server` did this until the upload needed to work. freeq's | 4 | `python3 -m http.server` did this until the upload needed to work. freeq's |
| 5 | media endpoint answers `access-control-allow-origin` for exactly one origin | 5 | media endpoint answers `access-control-allow-origin` for exactly one origin |
| @@ -17,12 +17,21 @@ no CORS at all -- the browser is not being circumvented, it is being told | |||
| 17 | the truth. This process makes the cross-origin request, where the rule does | 17 | the truth. This process makes the cross-origin request, where the rule does |
| 18 | not apply, and hands back what came. | 18 | not apply, and hands back what came. |
| 19 | 19 | ||
| 20 | -Two things this is deliberately not. It is not a general proxy: one path, | 20 | +`/api/v1/og` is here for the same reason and was found the same way: link |
| 21 | -one method, one upstream, fixed here rather than taken from the request, so | 21 | +previews shipped asking freeq directly, every fetch was blocked by the |
| 22 | -it cannot be pointed at anything. And it is not a permanent answer -- one | 22 | +allowlist, and on the screen that read as a feature that simply did nothing. |
| 23 | -`Access-Control-Allow-Origin: *` on a public, unauthenticated endpoint would | 23 | +The difference from the upload is only which way the bytes go -- a GET with |
| 24 | -retire it, and that ask is now the small one because the page sends no | 24 | +the page's `?url=` passed through, and the OpenGraph JSON handed back. |
| 25 | -credentials. | 25 | + |
| 26 | +Two things this is deliberately not. It is not a general proxy: two paths, | ||
| 27 | +one method each, one upstream, fixed here rather than taken from the | ||
| 28 | +request, so it cannot be pointed at anything. The `url` a caller passes is | ||
| 29 | +freeq's business, not this server's -- it is the one that fetches it, and it | ||
| 30 | +resolves the host and refuses the private ranges before it does. | ||
| 31 | + | ||
| 32 | +And it is not a permanent answer -- one `Access-Control-Allow-Origin: *` on | ||
| 33 | +a public, unauthenticated endpoint would retire both, and that ask is now | ||
| 34 | +the small one because the page sends no credentials. | ||
| 26 | 35 | ||
| 27 | python3 tools/webserve.py [PORT] [DIRECTORY] | 36 | python3 tools/webserve.py [PORT] [DIRECTORY] |
| 28 | 37 | ||
| @@ -38,6 +47,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer | |||
| 38 | 47 | ||
| 39 | UPSTREAM = os.environ.get("FRQ_API_ORIGIN", "https://irc.freeq.at").rstrip("/") | 48 | UPSTREAM = os.environ.get("FRQ_API_ORIGIN", "https://irc.freeq.at").rstrip("/") |
| 40 | UPLOAD = "/api/v1/upload" | 49 | UPLOAD = "/api/v1/upload" |
| 50 | +OG = "/api/v1/og" | ||
| 41 | 51 | ||
| 42 | # The endpoint's own cap. Refused here rather than after twelve megabytes | 52 | # The endpoint's own cap. Refused here rather than after twelve megabytes |
| 43 | # have crossed this machine on their way to being turned down. | 53 | # have crossed this machine on their way to being turned down. |
| @@ -79,10 +89,51 @@ class Handler(SimpleHTTPRequestHandler): | |||
| 79 | no body. A round trip per file, against a fix that does not arrive. | 89 | no body. A round trip per file, against a fix that does not arrive. |
| 80 | """ | 90 | """ |
| 81 | path = self.path.split("?")[0] | 91 | path = self.path.split("?")[0] |
| 82 | - if not path.startswith(UPLOAD): | 92 | + if path not in (UPLOAD, OG): |
| 83 | self.send_header("Cache-Control", "no-cache") | 93 | self.send_header("Cache-Control", "no-cache") |
| 84 | SimpleHTTPRequestHandler.end_headers(self) | 94 | SimpleHTTPRequestHandler.end_headers(self) |
| 85 | 95 | ||
| 96 | + def do_GET(self): | ||
| 97 | + """The bundle, or the preview relay. | ||
| 98 | + | ||
| 99 | + Everything but one path is a file, so the base class answers first | ||
| 100 | + and this only steps in front of it. | ||
| 101 | + """ | ||
| 102 | + if self.path.split("?")[0] != OG: | ||
| 103 | + SimpleHTTPRequestHandler.do_GET(self) | ||
| 104 | + return | ||
| 105 | + | ||
| 106 | + query = self.path.split("?", 1)[1] if "?" in self.path else "" | ||
| 107 | + if not query.startswith("url="): | ||
| 108 | + self.send_error(400, "url is required") | ||
| 109 | + return | ||
| 110 | + | ||
| 111 | + # Nothing of the caller's is forwarded -- no cookies, no | ||
| 112 | + # Authorization, nothing this server was trusted with. The query is | ||
| 113 | + # already encoded by whoever built it and is passed through as it | ||
| 114 | + # came, since re-encoding it here would be a second opinion about | ||
| 115 | + # what the URL was. | ||
| 116 | + req = urllib.request.Request( | ||
| 117 | + UPSTREAM + OG + "?" + query, method="GET", | ||
| 118 | + ) | ||
| 119 | + try: | ||
| 120 | + with urllib.request.urlopen(req, timeout=30) as r: | ||
| 121 | + status, body, kind = r.status, r.read(), r.headers.get_content_type() | ||
| 122 | + except urllib.error.HTTPError as e: | ||
| 123 | + # freeq's own refusal, passed through with its reason -- "Body | ||
| 124 | + # too large" for a page over its cap, "Blocked" for a host it | ||
| 125 | + # will not resolve. A 502 in place of those would lose the only | ||
| 126 | + # sentence saying what happened. | ||
| 127 | + status, body, kind = e.code, e.read(), "application/json" | ||
| 128 | + except OSError as e: | ||
| 129 | + status, body, kind = 502, str(e).encode(), "text/plain" | ||
| 130 | + | ||
| 131 | + self.send_response(status) | ||
| 132 | + self.send_header("Content-Type", kind) | ||
| 133 | + self.send_header("Content-Length", str(len(body))) | ||
| 134 | + self.end_headers() | ||
| 135 | + self.wfile.write(body) | ||
| 136 | + | ||
| 86 | def do_POST(self): | 137 | def do_POST(self): |
| 87 | if self.path.split("?")[0] != UPLOAD: | 138 | if self.path.split("?")[0] != UPLOAD: |
| 88 | self.send_error(404, "Not Found") | 139 | self.send_error(404, "Not Found") |
| @@ -143,7 +194,7 @@ def main() -> None: | |||
| 143 | handler = partial(Handler, directory=directory) | 194 | handler = partial(Handler, directory=directory) |
| 144 | srv = ThreadingHTTPServer(("", port), handler) | 195 | srv = ThreadingHTTPServer(("", port), handler) |
| 145 | print(f"serving {directory} on http://localhost:{port}" | 196 | print(f"serving {directory} on http://localhost:{port}" |
| 146 | - f" ({UPLOAD} relays to {UPSTREAM})", flush=True) | 197 | + f" ({UPLOAD} and {OG} relay to {UPSTREAM})", flush=True) |
| 147 | srv.serve_forever() | 198 | srv.serve_forever() |
| 148 | 199 | ||
| 149 | 200 | ||