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
|
"""
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"])
# Assets the shell pulls in, in load order. 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.
# Every module the page loads. A file missing from here is a file whose change
# does not move the URL, so a browser holding the old one never asks for it —
# which is the failure this list exists to prevent, and it is silent.
_ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
"i18n.js", "downloads.js", "transfers.js", "zipstream.js",
"platform.js", "meshbay-m.png",
# Split out of app.js by the group-page refactor — each imported by
# app.js or group-page.js, so a change to any of them is a change
# to what the browser must fetch.
"icon.js", "file-utils.js", "hub-client.js", "apps.js",
"chat-app.js", "files-app.js", "video-player.js", "video-app.js",
"music-app.js", "music-player.js", "photos-app.js",
"group-settings.js", "group-page.js",
# Pages extracted from app.js — statically imported or lazy-loaded,
# but all must participate in the content hash.
"auth-page.js", "explore-page.js", "create-group-page.js",
"admin-page.js", "node-page.js", "group-name.js",
"search-page.js", "settings-page.js", "profile-page.js")
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.
"""
h = hashlib.sha256()
for name in _ASSETS:
path = STATIC_DIR / name
if path.exists():
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 *same* policy the desktop client's protocol handler already sends
# for these exact UI files (`meshbay-client/src/main.js`), plus the two reCAPTCHA
# hosts the sign-up widget loads its script, challenge iframe and images from.
# `'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'",
f"frame-src {_RECAPTCHA_SRC}",
"frame-ancestors 'none'",
"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">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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. -->
<!-- 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)
|