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

serve.py · 185 lines · 8.3 KBPython Blame HistoryRaw
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago1"""The built web app, served from the volume the container built it into.
2
3Not part of `container.py`, and not a key in `container.toml`, because it is
4the other half of a split the build already makes: the Sandbox compiles into
5`/devshell/frq-flutter-web` and exits, and what it leaves behind is a
6directory of static files that outlives it. A Function mounting the same
7volume can hand those out without rebuilding anything, and can scale to zero
8between readers -- which a Sandbox holding a tunnel open cannot.
9
10 modal serve .modal/flutter-web/serve.py # while editing, auto-reloads
11 modal deploy .modal/flutter-web/serve.py # a URL that stays
12
13Deliberately NOT built on the container's own image. That image carries the
14repo and a few gigabytes of devShell closure, all of it for a compile that
15has already happened somewhere else; the bytes this serves come off the
16volume, so the image needs a python and nothing more. Cold starts are the
17difference.
18"""
19
20import json
21import subprocess
22
23import modal
24
25# The same volume the container writes to, named the same way. `from_name` is
26# lazy, so naming it here costs nothing until a container actually mounts it.
27DEVSHELL = modal.Volume.from_name("devshell", create_if_missing=True)
28
29# Where `just flutter-web` leaves its output, under this devShell's own
30# directory on the shared volume -- the same path container.toml builds in.
31WEB_ROOT = "/devshell/frq-flutter-web/flutter/build/web"
32
33PORT = 8080
34
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago35# 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.
47PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app")
48
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago49# This app's own origin, and the one thing here that is not derivable: an
50# OAuth client_id in AT Protocol *is* the URL its metadata is served from, so
51# the document has to name the origin it will be fetched from. Modal's
52# deployed URL is stable for a given app name, which is what makes that safe
53# to write down.
54ORIGIN = "https://codegod100--frq-flutter-web-serve-web.modal.run"
55
56# The OAuth client identity, served at `/client-metadata.json`.
57#
58# This is what makes Bluesky sign-in possible from this origin at all. freeq's
59# auth broker will only redirect to hosts on its own allowlist, and this one is
60# not among them -- but a client that runs the OAuth flow *itself* is not asking
61# the broker for anything. An AT Protocol authorization server fetches this
62# document from the client_id URL and takes it as the authority on where a code
63# may be sent, so the allowlist that matters is the `redirect_uris` below, which
64# we publish.
65#
66# A public client: `token_endpoint_auth_method: none` and no secret, because a
67# page in a browser can keep none. What stands in for one is DPoP -- every token
68# is bound to a key the client proves it holds, which is also exactly what
69# freeq's SASL `pds-oauth` method verifies.
70CLIENT_METADATA = {
71 "client_id": f"{ORIGIN}/client-metadata.json",
72 "client_name": "frq",
73 "client_uri": f"{ORIGIN}/",
74 "redirect_uris": [f"{ORIGIN}/"],
75 "grant_types": ["authorization_code", "refresh_token"],
76 "response_types": ["code"],
77 # `atproto` is the identity scope freeq needs; `transition:generic` is what
78 # a PDS still wants for ordinary reads and writes.
79 "scope": "atproto transition:generic",
80 "token_endpoint_auth_method": "none",
81 "application_type": "web",
82 "dpop_bound_access_tokens": True,
83}
84
85app = modal.App("frq-flutter-web-serve")
86
87image = modal.Image.debian_slim(python_version="3.12")
88
89
90@app.function(
91 image=image,
92 volumes={"/devshell": DEVSHELL},
93 # Nothing here holds state between requests, and a reader who wanders off
94 # should stop costing anything -- so let it go to zero quickly rather than
95 # keeping a container warm for a static directory.
96 scaledown_window=60,
97)
98@modal.web_server(PORT, startup_timeout=30)
99def web():
100 # A Volume mount is a snapshot taken when the container starts, so a
101 # container that outlives a build would keep serving the old one. Reload
102 # first and the newest committed build is what gets served -- which is the
103 # whole point of the split: rebuild in the Sandbox, and the next cold
104 # start here picks it up with nothing redeployed.
105 DEVSHELL.reload()
106
107 # The client metadata is written beside the bundle rather than served by a
108 # route of its own: `http.server` has no routing table, and one file on
109 # disk is less machinery than a handler subclass. Written at start-up and
110 # not baked into the build, because it names the deployed origin, which is
111 # a property of this Function and not of the ClojureDart.
112 #
113 # The volume is shared and durable, so this also survives for the next
114 # cold start; rewriting it every time is what keeps ORIGIN and the file in
115 # step when one of them changes.
116 with open(f"{WEB_ROOT}/client-metadata.json", "w") as f:
117 json.dump(CLIENT_METADATA, f, indent=2)
118 DEVSHELL.commit()
119
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago120 # `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"""
125import http.server, os, socketserver, urllib.parse, urllib.request
126
127ROOT = {WEB_ROOT!r}
128HOSTS = {PROXY_HOSTS!r}
129
130class 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
171class 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
177S(("", {PORT}), H).serve_forever()
178"""
179 with open("/tmp/frq_serve.py", "w") as f:
180 f.write(server)
181
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago182 # Popen and not run: `@modal.web_server` expects the body to *start* a
183 # server and return, so Modal can begin proxying. Blocking here would time
184 # out at startup_timeout with nothing ever listening.
Pictures a browser will accept, through the one origin that vouches for them 1a1f153 nandi 2d ago185 subprocess.Popen(["python", "/tmp/frq_serve.py"])