1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
|
"""
Hub web application — serves the MeshBay SPA and static assets.
The SPA (Preact + htm) handles:
- Authentication (login, register, token refresh)
- Group discovery and browsing
- WebRTC connection to nodes for P2P file transfer
- Dark/light theme with system preference detection
Static files are served from meshbay_hub/static/ via Starlette StaticFiles.
The root route (/) returns the SPA HTML shell.
"""
import hashlib
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
STATIC_DIR = Path(__file__).parent.parent / "static"
router = APIRouter(tags=["webapp"])
# The shell loads a few assets by name; everything else is imported by app.js
# from a relative path, which inherits the `/a/<hash>/` prefix the shell loaded
# app.js under — so the whole module graph moves together.
def _asset_version() -> str:
"""A fingerprint of what we are actually serving.
`Cache-Control: no-cache` only binds a browser that asks. One that cached
app.js *before* that header existed applies heuristic freshness — a
fraction of the file's age, which for a file dated weeks ago is days — and
never asks at all. It then runs last week's player against this week's node
for as long as that lasts, which is indistinguishable from the fix not
working. Changing the URL is the only thing that reaches such a browser,
and a content hash changes it exactly when the content changes.
**Every file under the static directory**, because that is what `/a/<hash>/`
serves — with a year's `immutable`, so a file outside the hash is a file a
browser never fetches again. The fingerprint used to cover a hand-kept list
of top-level modules, and the translations and `vendor/` were not on it: a
change confined to the catalogues kept the hash, and a phone went on showing
a heading that had been rewritten and deployed. The path is hashed with the
content, so a rename or a new file moves the version too.
"""
h = hashlib.sha256()
for path in sorted(p for p in STATIC_DIR.rglob("*") if p.is_file()):
h.update(path.relative_to(STATIC_DIR).as_posix().encode() + b"\0")
h.update(path.read_bytes())
return h.hexdigest()[:12]
ASSET_V = _asset_version()
# No Cache-Control here meant no explicit signal either way, and a browser
# left to its own heuristics can decide this is fresh enough without asking
# — which nothing about a subsequent reload, pull-to-refresh included,
# is guaranteed to override. `{v}` only reaches the browser at all if this
# shell itself is refetched; a heuristically-cached copy of it re-serves the
# OLD hash and therefore the old JS forever, indistinguishable from a fix not
# working. `no-store` forces every navigation here to hit the network, which
# is the only way `{v}` can ever change what a browser holding an old page
# actually asks for next.
_NO_STORE = {"Cache-Control": "no-store"}
# Content-Security-Policy for the whole hub, applied by a middleware in app.py.
#
# This is the desktop client's policy for these exact UI files
# (`meshbay-client/src/main.js`), which serves the same interface, and the two
# have to be changed together — a directive added here and not there breaks the
# app, and the reverse leaves the browser behind. They differ in two places, on
# purpose: `frame-ancestors` is `'self'` here and `'none'` there (nothing frames
# an `app://` page), and `frame-src` carries `'self'` here for the streamed
# download's hidden iframe, which the client has no service worker for.
# `'unsafe-inline'` is style-only — htm/preact set inline `style=` attributes
# everywhere; nothing inline executes, and the shell below carries no inline
# `<script>`. `'wasm-unsafe-eval'` is required for the Argon2id WASM. The hub's
# own origin is deliberately absent from `script-src`: a response it returns is
# never executed, which is the point of T3.
_RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"
CSP = "; ".join([
"default-src 'none'",
f"script-src 'self' 'wasm-unsafe-eval' {_RECAPTCHA_SRC}",
"style-src 'self' 'unsafe-inline'",
f"img-src 'self' data: blob: {_RECAPTCHA_SRC}",
"media-src 'self' blob:",
"font-src 'self'",
"connect-src 'self' https: wss:",
"worker-src 'self'",
# `object-src` exists for one thing, and `frame-src`'s `blob:` for the same
# thing: previewing a PDF without writing it anywhere.
#
# `files-app.js` decrypts the file in the page, wraps it in a Blob and hands
# it to `<object type="application/pdf">`. Chromium renders that with its own
# viewer, which needs TWO permissions — the resource is loaded as plugin data
# (`object-src`) and then rendered in an internal frame (`frame-src`). This
# policy had neither: `object-src` was absent, so it fell back to
# `default-src 'none'`, and the fallback message ("this browser will not
# display the PDF inline") was shown on every platform from the day this CSP
# shipped. It read as a desktop-client limitation because the client has sent
# a CSP since its first launch and the hub had none before this file.
#
# `'self'` does not cover a same-origin `blob:` URL in either directive —
# measured, not assumed, in Chrome 152 against the deployed page — so the
# token is `blob:` and it is needed in both. Widening `object-src` from
# `'none'` admits only what page script minted itself, at a type this code
# sets: PDFium parsing bytes that came from a node, which is what any
# browser does with the same file once it is downloaded.
"object-src blob:",
# `'self'` is not decoration: the streamed-download path works by navigating
# a hidden iframe to `/_mbdl/<id>` so the service worker is asked for the
# response it is holding. Without it Chrome refuses the frame, the worker is
# never asked, and the page waits out its timeout for a download that cannot
# happen — on Firefox and Safari that is the *only* way to write a large
# file to disk, so the whole path was dead. Added when reCAPTCHA needed a
# frame, which is why nobody connected the two.
f"frame-src 'self' blob: {_RECAPTCHA_SRC}",
# `'self'`, not `'none'`, and the difference is one same-origin iframe.
#
# The threat frame-ancestors answers is clickjacking: a *foreign* page
# framing this one and stealing clicks. `'self'` refuses every foreign
# origin exactly as `'none'` does — what it additionally allows is this
# origin framing itself, which is precisely how a streamed download works
# (a hidden iframe navigates to `/_mbdl/<id>` so the service worker is
# asked for the response it holds).
#
# Under `'none'` Firefox blocked that frame, the worker was never asked,
# and every large download waited out two 15-second timeouts and then fell
# through — on Firefox and Safari that is the only way to write a large
# file to disk. Chrome did not show it: its worker intercepts the
# navigation before the network response and its CSP are ever considered,
# which is why this looked like a Firefox-only problem for an afternoon.
"frame-ancestors 'self'",
"base-uri 'none'",
"form-action 'none'",
])
@router.get("/app", response_class=HTMLResponse)
async def app_root():
return HTMLResponse(_HTML, headers=_NO_STORE)
@router.get("/app/{path:path}", response_class=HTMLResponse)
async def app_catchall(path: str):
return HTMLResponse(_HTML, headers=_NO_STORE)
@router.get("/", response_class=HTMLResponse)
async def index():
return HTMLResponse(_HTML, headers=_NO_STORE)
_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- `interactive-widget=resizes-content`: on Android the soft keyboard
shrinks the visual viewport and leaves the layout viewport alone, which
is the platform default. `position: sticky` anchors to the layout
viewport, so with a keyboard up the pinned header of a group or of
Search sits at a coordinate the reader can no longer see — it reads as
though it had scrolled away. This asks for the keyboard to resize the
layout viewport instead, so what is pinned stays where it is looked at.
Ignored by browsers that do not know it. -->
<meta name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<title>MeshBay</title>
<link rel="stylesheet" href="/a/{v}/style.css">
</head>
<body>
<div id="app"></div>
<!-- Everything below is loaded from a path that carries the fingerprint of
what we are serving, so a browser holding a heuristically-cached copy of
an older build fetches this one instead of deciding it need not ask —
and app.js's own relative imports inherit the prefix, which is the only
way the module graph is guaranteed not to be a mixture of two builds.
See _asset_version() and VersionedStatics. -->
<!-- First, and not a module: if the graph below never links, no module code
runs at all, and this is what keeps the page from being silently blank.
It draws nothing unless #app is still empty ten seconds from now. -->
<script src="/a/{v}/boot-guard.js"></script>
<!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the
keypair bundle needs one: it is protected by the passphrase alone and sits
on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md -->
<script src="/a/{v}/vendor/argon2.min.js"></script>
<script src="/a/{v}/keyderive.js"></script>
<script src="/a/{v}/crypto.js"></script>
<script src="/a/{v}/transport.js"></script>
<script type="module" src="/a/{v}/app.js"></script>
</body>
</html>
""".replace("{v}", ASSET_V)
|