nandi/frqpublic Fork
⑂ 391922b
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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>
nandi committed 2026-09-20T17:20:48-07:00 Browse files
391922b parent: 050629b
modified nim/web/frq/linkfetch.nim +13 -2
@@ -8,6 +8,14 @@
88 ## Same endpoint, and here it is the only one that would work at all: a page
99 ## cannot read a third-party site's HTML, so `/api/v1/og` is both the private
1010 ## 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`.
1119
1220 import std/deques
1321 import std/json
@@ -33,8 +41,11 @@ proc arrived(url, body: cstring) =
3341 proc want*(host, url: string) =
3442 ## Once per link for the run, as on the desktop.
3543 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)
3849 remember(url, Preview(status: lsLoading))
3950 fetchPreview(endpoint.cstring, url.cstring, arrived)
4051
@@ -8,6 +8,14 @@
8 ## Same endpoint, and here it is the only one that would work at all: a page8 ## 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 private9 ## 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/deques20 import std/deques
13 import std/json21 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): return43 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: return45+ # 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 @@
11 #!/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.
33
44 `python3 -m http.server` did this until the upload needed to work. freeq's
55 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
1717 the truth. This process makes the cross-origin request, where the rule does
1818 not apply, and hands back what came.
1919
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.
2635
2736 python3 tools/webserve.py [PORT] [DIRECTORY]
2837
@@ -38,6 +47,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
3847
3948 UPSTREAM = os.environ.get("FRQ_API_ORIGIN", "https://irc.freeq.at").rstrip("/")
4049 UPLOAD = "/api/v1/upload"
50+OG = "/api/v1/og"
4151
4252 # The endpoint's own cap. Refused here rather than after twelve megabytes
4353 # have crossed this machine on their way to being turned down.
@@ -79,10 +89,51 @@ class Handler(SimpleHTTPRequestHandler):
7989 no body. A round trip per file, against a fix that does not arrive.
8090 """
8191 path = self.path.split("?")[0]
82- if not path.startswith(UPLOAD):
92+ if path not in (UPLOAD, OG):
8393 self.send_header("Cache-Control", "no-cache")
8494 SimpleHTTPRequestHandler.end_headers(self)
8595
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+
86137 def do_POST(self):
87138 if self.path.split("?")[0] != UPLOAD:
88139 self.send_error(404, "Not Found")
@@ -143,7 +194,7 @@ def main() -> None:
143194 handler = partial(Handler, directory=directory)
144195 srv = ThreadingHTTPServer(("", port), handler)
145196 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)
147198 srv.serve_forever()
148199
149200
@@ -1,5 +1,5 @@
1 #!/usr/bin/env python31 #!/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's4 `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 origin5 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 does17 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, so21+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 -- one22+allowlist, and on the screen that read as a feature that simply did nothing.
23-`Access-Control-Allow-Origin: *` on a public, unauthenticated endpoint would23+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 no24+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 megabytes52 # 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