""" 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//` 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", "source-merge.js", "sticky.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", # The per-app settings architecture (docs/refactor-groups.md §3): # the shared widgets, the folder picker, and one settings pane per # app. Reached through the `apps.js` registry rather than imported # by name anywhere, which is exactly why they have to be listed — # nothing else would notice one of them changing. "settings-ui.js", "folder-tree.js", "chat-app-settings.js", "video-app-settings.js", "music-app-settings.js", "photos-app-settings.js", # The reference app (docs/refactor-groups.md §4.1). Hidden behind # `?dev=1` client-side, but it is still served and still cached, so # it participates in the hash like anything else here. "helloworld-app.js", "helloworld-app-settings.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 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 # ` """.replace("{v}", ASSET_V)