nandi/frqpublic Fork 0
1a1f153
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.

Pictures a browser will accept, through the one origin that vouches for them

`cdn.bsky.app` serves avatars with no `Access-Control-Allow-Origin`, and
Flutter loads images over XHR on the web — so the bytes arrived and were
thrown away for want of a header the far side does not send. Every face in
the room was a blank circle. Nothing in the client can answer that; the
header is not ours to send, and neither the desktop nor the APK is a browser.

So the server this build is served from fetches them and says the header
itself. An allowlist and not a wildcard — a proxy that fetches anything is an
open relay wearing our origin — and `irc.freeq.at` is on it for a second
reason: freeq serves pasted pictures under /api/v1/media, and those are the
same `:image` in the same chat.

`frq.hiccup/image-url` is where the rewrite lands, an atom defaulting to
identity that `frq.main-web` fills in. Both network-image sites go through it;
the other two targets never call it.

The rest is what a committed `flutter/web/` needs to stop 404ing: the manifest
and the icons that `index.html` has been asking for since it stopped being
generated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-17T02:16:03-07:00 Browse files
1a1f153 parent: a9954d9
modified .modal/flutter-web/container.toml +4 -0
@@ -62,6 +62,10 @@ ignore = [
6262 "flutter/build", "flutter/.home", "flutter/.dart_tool",
6363 "flutter/.clojuredart", "flutter/.cpcache",
6464 ".jolt", ".cpcache", "result", "build", ".git",
65+ # An editor's linter rewrites this while the upload is reading it, and
66+ # Modal fails the whole run with "was modified during build process".
67+ # Nothing out here reads it.
68+ ".clj-kondo",
6569 ]
6670
6771 # `nix-cache` is the binary cache every container here reads from and writes
@@ -62,6 +62,10 @@ ignore = [
62 "flutter/build", "flutter/.home", "flutter/.dart_tool",62 "flutter/build", "flutter/.home", "flutter/.dart_tool",
63 "flutter/.clojuredart", "flutter/.cpcache",63 "flutter/.clojuredart", "flutter/.cpcache",
64 ".jolt", ".cpcache", "result", "build", ".git",64 ".jolt", ".cpcache", "result", "build", ".git",
65+ # An editor's linter rewrites this while the upload is reading it, and
66+ # Modal fails the whole run with "was modified during build process".
67+ # Nothing out here reads it.
68+ ".clj-kondo",
65 ]69 ]
66 70
67 # `nix-cache` is the binary cache every container here reads from and writes71 # `nix-cache` is the binary cache every container here reads from and writes
modified .modal/flutter-web/serve.py +77 -11
@@ -32,6 +32,20 @@ WEB_ROOT = "/devshell/frq-flutter-web/flutter/build/web"
3232
3333 PORT = 8080
3434
35+# Hosts the image proxy will fetch from. An allowlist and not a wildcard: a
36+# proxy that fetches anything is an open proxy, and this one answers on a
37+# public URL.
38+#
39+# Why it exists at all: `cdn.bsky.app` serves avatars with NO
40+# `Access-Control-Allow-Origin` header, and Flutter web loads images through
41+# XHR — so the browser fetches the bytes, sees no CORS header, and throws them
42+# away. Nothing in the client can change that; the header is the far side's to
43+# send. The desktop and the APK are unaffected, because neither is a browser.
44+#
45+# `irc.freeq.at` is here for the same reason and a second one: freeq serves
46+# pasted images under /api/v1/media, and those are `:image` in the same chat.
47+PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app")
48+
3549 # This app's own origin, and the one thing here that is not derivable: an
3650 # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
3751 # the document has to name the origin it will be fetched from. Modal's
@@ -103,17 +117,69 @@ def web():
103117 json.dump(CLIENT_METADATA, f, indent=2)
104118 DEVSHELL.commit()
105119
120+ # `http.server` with one route bolted on, rather than `-m http.server`:
121+ # static files are still all the app wants on first load, but images need
122+ # somewhere same-origin to come from. Written out and run as a file
123+ # because `@modal.web_server` wants a process, not a handler object.
124+ server = f"""
125+import http.server, os, socketserver, urllib.parse, urllib.request
126+
127+ROOT = {WEB_ROOT!r}
128+HOSTS = {PROXY_HOSTS!r}
129+
130+class H(http.server.SimpleHTTPRequestHandler):
131+ def __init__(self, *a, **kw):
132+ super().__init__(*a, directory=ROOT, **kw)
133+
134+ def do_OPTIONS(self):
135+ # The preflight. A cross-origin image fetch does not send one, but the
136+ # XHRs that read freeq's API do, and answering it is two lines.
137+ self.send_response(204)
138+ self.send_header("Access-Control-Allow-Origin", "*")
139+ self.send_header("Access-Control-Allow-Headers", "*")
140+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
141+ self.end_headers()
142+
143+ def do_GET(self):
144+ if not self.path.startswith("/proxy?"):
145+ return super().do_GET()
146+ q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
147+ target = (q.get("url") or [""])[0]
148+ parts = urllib.parse.urlparse(target)
149+ # Allowlisted https hosts only. Anything else and this is an open
150+ # relay wearing our origin.
151+ if parts.scheme != "https" or parts.hostname not in HOSTS:
152+ self.send_error(403, "host not proxied")
153+ return
154+ try:
155+ req = urllib.request.Request(target, headers={{"user-agent": "frq"}})
156+ with urllib.request.urlopen(req, timeout=30) as up:
157+ body = up.read()
158+ ctype = up.headers.get("content-type", "application/octet-stream")
159+ except Exception as e:
160+ self.send_error(502, f"upstream: {{e}}")
161+ return
162+ self.send_response(200)
163+ self.send_header("Content-Type", ctype)
164+ self.send_header("Content-Length", str(len(body)))
165+ # The whole point: our origin says yes where the far side said nothing.
166+ self.send_header("Access-Control-Allow-Origin", "*")
167+ self.send_header("Cache-Control", "public, max-age=3600")
168+ self.end_headers()
169+ self.wfile.write(body)
170+
171+class S(socketserver.ThreadingTCPServer):
172+ # Threaded, because a proxied fetch blocks: one slow avatar must not stop
173+ # the page loading. daemon_threads so the process can still exit.
174+ allow_reuse_address = True
175+ daemon_threads = True
176+
177+S(("", {PORT}), H).serve_forever()
178+"""
179+ with open("/tmp/frq_serve.py", "w") as f:
180+ f.write(server)
181+
106182 # Popen and not run: `@modal.web_server` expects the body to *start* a
107183 # server and return, so Modal can begin proxying. Blocking here would time
108184 # out at startup_timeout with nothing ever listening.
109- #
110- # `python -m http.server` and not something with a routing table: Flutter
111- # writes a service worker and an index.html and asks for its own assets by
112- # exact path, so plain static serving is all the app wants on first load.
113- # What it does not give is a fallback for a deep link -- Flutter's default
114- # URL strategy is real paths, so /chats reaches the server rather than the
115- # router and gets a 404. That wants a real fallback-to-index server, and
116- # is worth having only once there is a router out there to reach.
117- subprocess.Popen(
118- ["python", "-m", "http.server", str(PORT), "--directory", WEB_ROOT],
119- )
185+ subprocess.Popen(["python", "/tmp/frq_serve.py"])
@@ -32,6 +32,20 @@ WEB_ROOT = "/devshell/frq-flutter-web/flutter/build/web"
32 32
33 PORT = 808033 PORT = 8080
34 34
35+# Hosts the image proxy will fetch from. An allowlist and not a wildcard: a
36+# proxy that fetches anything is an open proxy, and this one answers on a
37+# public URL.
38+#
39+# Why it exists at all: `cdn.bsky.app` serves avatars with NO
40+# `Access-Control-Allow-Origin` header, and Flutter web loads images through
41+# XHR — so the browser fetches the bytes, sees no CORS header, and throws them
42+# away. Nothing in the client can change that; the header is the far side's to
43+# send. The desktop and the APK are unaffected, because neither is a browser.
44+#
45+# `irc.freeq.at` is here for the same reason and a second one: freeq serves
46+# pasted images under /api/v1/media, and those are `:image` in the same chat.
47+PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app")
48+
35 # This app's own origin, and the one thing here that is not derivable: an49 # This app's own origin, and the one thing here that is not derivable: an
36 # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so50 # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
37 # the document has to name the origin it will be fetched from. Modal's51 # the document has to name the origin it will be fetched from. Modal's
@@ -103,17 +117,69 @@ def web():
103 json.dump(CLIENT_METADATA, f, indent=2)117 json.dump(CLIENT_METADATA, f, indent=2)
104 DEVSHELL.commit()118 DEVSHELL.commit()
105 119
120+ # `http.server` with one route bolted on, rather than `-m http.server`:
121+ # static files are still all the app wants on first load, but images need
122+ # somewhere same-origin to come from. Written out and run as a file
123+ # because `@modal.web_server` wants a process, not a handler object.
124+ server = f"""
125+import http.server, os, socketserver, urllib.parse, urllib.request
126+
127+ROOT = {WEB_ROOT!r}
128+HOSTS = {PROXY_HOSTS!r}
129+
130+class H(http.server.SimpleHTTPRequestHandler):
131+ def __init__(self, *a, **kw):
132+ super().__init__(*a, directory=ROOT, **kw)
133+
134+ def do_OPTIONS(self):
135+ # The preflight. A cross-origin image fetch does not send one, but the
136+ # XHRs that read freeq's API do, and answering it is two lines.
137+ self.send_response(204)
138+ self.send_header("Access-Control-Allow-Origin", "*")
139+ self.send_header("Access-Control-Allow-Headers", "*")
140+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
141+ self.end_headers()
142+
143+ def do_GET(self):
144+ if not self.path.startswith("/proxy?"):
145+ return super().do_GET()
146+ q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
147+ target = (q.get("url") or [""])[0]
148+ parts = urllib.parse.urlparse(target)
149+ # Allowlisted https hosts only. Anything else and this is an open
150+ # relay wearing our origin.
151+ if parts.scheme != "https" or parts.hostname not in HOSTS:
152+ self.send_error(403, "host not proxied")
153+ return
154+ try:
155+ req = urllib.request.Request(target, headers={{"user-agent": "frq"}})
156+ with urllib.request.urlopen(req, timeout=30) as up:
157+ body = up.read()
158+ ctype = up.headers.get("content-type", "application/octet-stream")
159+ except Exception as e:
160+ self.send_error(502, f"upstream: {{e}}")
161+ return
162+ self.send_response(200)
163+ self.send_header("Content-Type", ctype)
164+ self.send_header("Content-Length", str(len(body)))
165+ # The whole point: our origin says yes where the far side said nothing.
166+ self.send_header("Access-Control-Allow-Origin", "*")
167+ self.send_header("Cache-Control", "public, max-age=3600")
168+ self.end_headers()
169+ self.wfile.write(body)
170+
171+class S(socketserver.ThreadingTCPServer):
172+ # Threaded, because a proxied fetch blocks: one slow avatar must not stop
173+ # the page loading. daemon_threads so the process can still exit.
174+ allow_reuse_address = True
175+ daemon_threads = True
176+
177+S(("", {PORT}), H).serve_forever()
178+"""
179+ with open("/tmp/frq_serve.py", "w") as f:
180+ f.write(server)
181+
106 # Popen and not run: `@modal.web_server` expects the body to *start* a182 # Popen and not run: `@modal.web_server` expects the body to *start* a
107 # server and return, so Modal can begin proxying. Blocking here would time183 # server and return, so Modal can begin proxying. Blocking here would time
108 # out at startup_timeout with nothing ever listening.184 # out at startup_timeout with nothing ever listening.
109- #185+ subprocess.Popen(["python", "/tmp/frq_serve.py"])
110- # `python -m http.server` and not something with a routing table: Flutter
111- # writes a service worker and an index.html and asks for its own assets by
112- # exact path, so plain static serving is all the app wants on first load.
113- # What it does not give is a fallback for a deep link -- Flutter's default
114- # URL strategy is real paths, so /chats reaches the server rather than the
115- # router and gets a 404. That wants a real fallback-to-index server, and
116- # is worth having only once there is a router out there to reach.
117- subprocess.Popen(
118- ["python", "-m", "http.server", str(PORT), "--directory", WEB_ROOT],
119- )
added flutter/web/favicon.png +0 -0
new file mode 100644
Binary files /dev/null and b/flutter/web/favicon.png differ
new file mode 100644
Binary files /dev/null and b/flutter/web/favicon.png differBinary files /dev/null and b/flutter/web/favicon.png differ
added flutter/web/icons/Icon-192.png +0 -0
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-192.png differ
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-192.png differBinary files /dev/null and b/flutter/web/icons/Icon-192.png differ
added flutter/web/icons/Icon-512.png +0 -0
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-512.png differ
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-512.png differBinary files /dev/null and b/flutter/web/icons/Icon-512.png differ
added flutter/web/icons/Icon-maskable-192.png +0 -0
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-maskable-192.png differ
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-maskable-192.png differBinary files /dev/null and b/flutter/web/icons/Icon-maskable-192.png differ
added flutter/web/icons/Icon-maskable-512.png +0 -0
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-maskable-512.png differ
new file mode 100644
Binary files /dev/null and b/flutter/web/icons/Icon-maskable-512.png differBinary files /dev/null and b/flutter/web/icons/Icon-maskable-512.png differ
added flutter/web/manifest.json +35 -0
new file mode 100644
@@ -0,0 +1,35 @@
1+{
2+ "name": "frq",
3+ "short_name": "frq",
4+ "start_url": ".",
5+ "display": "standalone",
6+ "background_color": "#0175C2",
7+ "theme_color": "#0175C2",
8+ "description": "freeq client",
9+ "orientation": "portrait-primary",
10+ "prefer_related_applications": false,
11+ "icons": [
12+ {
13+ "src": "icons/Icon-192.png",
14+ "sizes": "192x192",
15+ "type": "image/png"
16+ },
17+ {
18+ "src": "icons/Icon-512.png",
19+ "sizes": "512x512",
20+ "type": "image/png"
21+ },
22+ {
23+ "src": "icons/Icon-maskable-192.png",
24+ "sizes": "192x192",
25+ "type": "image/png",
26+ "purpose": "maskable"
27+ },
28+ {
29+ "src": "icons/Icon-maskable-512.png",
30+ "sizes": "512x512",
31+ "type": "image/png",
32+ "purpose": "maskable"
33+ }
34+ ]
35+}
\ No newline at end of file
new file mode 100644
@@ -0,0 +1,35 @@
1+{
2+ "name": "frq",
3+ "short_name": "frq",
4+ "start_url": ".",
5+ "display": "standalone",
6+ "background_color": "#0175C2",
7+ "theme_color": "#0175C2",
8+ "description": "freeq client",
9+ "orientation": "portrait-primary",
10+ "prefer_related_applications": false,
11+ "icons": [
12+ {
13+ "src": "icons/Icon-192.png",
14+ "sizes": "192x192",
15+ "type": "image/png"
16+ },
17+ {
18+ "src": "icons/Icon-512.png",
19+ "sizes": "512x512",
20+ "type": "image/png"
21+ },
22+ {
23+ "src": "icons/Icon-maskable-192.png",
24+ "sizes": "192x192",
25+ "type": "image/png",
26+ "purpose": "maskable"
27+ },
28+ {
29+ "src": "icons/Icon-maskable-512.png",
30+ "sizes": "512x512",
31+ "type": "image/png",
32+ "purpose": "maskable"
33+ }
34+ ]
35+}
\ No newline at end of file\ No newline at end of file