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>
1a1f153 parent: a9954d9 modified
.modal/flutter-web/container.toml +4 -0 | @@ -62,6 +62,10 @@ ignore = [ | ||
| 62 | 62 | "flutter/build", "flutter/.home", "flutter/.dart_tool", |
| 63 | 63 | "flutter/.clojuredart", "flutter/.cpcache", |
| 64 | 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 | 71 | # `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 writes | 71 | # `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" | ||
| 32 | 32 | |
| 33 | 33 | 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 | 49 | # This app's own origin, and the one thing here that is not derivable: an |
| 36 | 50 | # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so |
| 37 | 51 | # the document has to name the origin it will be fetched from. Modal's |
| @@ -103,17 +117,69 @@ def web(): | ||
| 103 | 117 | json.dump(CLIENT_METADATA, f, indent=2) |
| 104 | 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 | 182 | # Popen and not run: `@modal.web_server` expects the body to *start* a |
| 107 | 183 | # server and return, so Modal can begin proxying. Blocking here would time |
| 108 | 184 | # 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 = 8080 | 33 | 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: an | 49 | # 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, so | 50 | # 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's | 51 | # 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* a | 182 | # 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 time | 183 | # 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 differ | Binary 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 differ | Binary 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 differ | Binary 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 differ | Binary 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 differ | Binary 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 | ||