""" MeshBay Hub — FastAPI application factory. Usage: from meshbay_hub.app import create_app from meshbay_hub.config import load_config cfg = load_config() app = create_app(cfg) """ import asyncio from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from meshbay_hub import __version__ from meshbay_hub.auth import generate_hub_keypair, load_hub_keypair from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import close_db, init_db from meshbay_hub.api.hub import router as hub_router, set_config as hub_set_config from meshbay_hub.api.users import router as users_router, set_config as users_set_config from meshbay_hub.api.deps import set_admin_usernames from meshbay_hub.api.nodes import router as nodes_router from meshbay_hub.api.groups import router as groups_router, swarm_router from meshbay_hub.api.revocation import router as revocation_router from meshbay_hub.api.moderation import router as moderation_router from meshbay_hub.api.federation import router as federation_router from meshbay_hub.csam import csam_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.signaling import router as signaling_router from meshbay_hub.api.admin import router as admin_router from meshbay_hub.api.notifications import router as notifications_router from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V, CSP from meshbay_hub.api.middleware import limiter async def _sync_admin_roles(admin_usernames: list[str]) -> None: """Ensure config-listed admin usernames have role='admin' in the DB.""" from sqlalchemy import select, update from meshbay_hub.db.engine import get_session_factory from meshbay_hub.db.models import User factory = get_session_factory() async with factory() as session: result = await session.execute( select(User).where(User.username.in_(admin_usernames)) ) for user in result.scalars().all(): if user.role != "admin": user.role = "admin" await session.commit() async def _backfill_email_hashes() -> None: """One-time backfill: compute email_hash for users that don't have one yet.""" import logging from sqlalchemy import select from meshbay_hub.auth import decrypt_email, hash_email_blind from meshbay_hub.db.engine import get_session_factory from meshbay_hub.db.models import User log = logging.getLogger(__name__) factory = get_session_factory() async with factory() as session: result = await session.execute( select(User).where(User.email_hash.is_(None), User.email.isnot(None)) ) users = list(result.scalars().all()) if not users: return count = 0 for user in users: try: plain = decrypt_email(user.email) user.email_hash = hash_email_blind(plain) count += 1 except Exception: log.warning("Could not backfill email_hash for user %s", user.id) await session.commit() log.info("Backfilled email_hash for %d users", count) def create_app(cfg: HubConfig | None = None) -> FastAPI: from meshbay_hub.config import load_config if cfg is None: cfg = load_config() @asynccontextmanager async def lifespan(app: FastAPI): # Startup await init_db(cfg.db.url) kp = cfg.identity.private_key_path if not kp.exists(): generate_hub_keypair(kp) load_hub_keypair(kp, cfg.identity.id) users_set_config(cfg) hub_set_config(cfg) set_admin_usernames(cfg.identity.admin_usernames) await _backfill_email_hashes() from meshbay_hub import hub_settings as _hub_settings from meshbay_hub import mail as _mail _mail.configure(cfg.identity.id) # What `hub.toml` says the mail bounds are. The live values are read # from `hub_settings` at each use, so an admin can change them from the # panel while the hub is serving; these are what a missing row falls # back to. _hub_settings.set_mail_defaults(cfg.mail) if cfg.identity.admin_usernames: await _sync_admin_roles(cfg.identity.admin_usernames) from meshbay_hub.csam import get_csam_checker get_csam_checker().load() from meshbay_hub.tasks.cleanup import cleanup_loop from meshbay_hub.db.engine import get_session_factory cleanup_task = asyncio.create_task(cleanup_loop(get_session_factory())) yield cleanup_task.cancel() try: await cleanup_task except asyncio.CancelledError: pass # Shutdown await close_db() app = FastAPI( title="MeshBay Hub", version=__version__, description="MeshBay identity authority and group registry", lifespan=lifespan, # No interactive docs and no schema. The full description of the # identity authority's API is a map for whoever probes it, and nothing # in the tree reads it. meshbay.org hid these in its Caddyfile (S18), # which protects exactly one deployment: a hub installed from the # package, behind any other proxy, published all three. docs_url=None, redoc_url=None, openapi_url=None, ) # Rate limiting app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @app.middleware("http") async def _security_headers(request, call_next): """ Second-review L5, third-review M5: the SPA shell and its assets went out with no CSP and no other protective headers. This adds them everywhere — `webapp.CSP` is the same policy the desktop client already enforces on these exact files. `setdefault` so a route that sets its own wins. """ response = await call_next(request) response.headers.setdefault("Content-Security-Policy", CSP) response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") # SAMEORIGIN, matching `frame-ancestors 'self'` in the CSP above. # # The two say the same thing to different generations of browser, and # they were saying different things: CSP allowed this origin to frame # itself, this header forbade all framing. The spec says a browser must # ignore X-Frame-Options when the CSP carries frame-ancestors — but # relying on that while shipping a header that contradicts our own # policy is asking to be surprised, and we were: the streamed download # (a hidden iframe onto `/_mbdl/`, the only way to write a large # file to disk on Firefox and Safari) stayed blocked after the CSP was # fixed, and this header was why it looked like the fix had not worked. # # No foreign origin may frame this page under either spelling. That is # the property; DENY was one notch stricter than the property needed and # broke a feature to get there. response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") return response # Routers (webapp last — catches / before API routes) app.include_router(hub_router) app.include_router(users_router) app.include_router(nodes_router) app.include_router(groups_router) app.include_router(swarm_router) app.include_router(revocation_router) app.include_router(moderation_router) app.include_router(federation_router) app.include_router(csam_router) app.include_router(health_router) app.include_router(relay_router) app.include_router(signaling_router) app.include_router(admin_router) app.include_router(notifications_router) app.include_router(webapp_router) from starlette.staticfiles import StaticFiles class RevalidatingStatics(StaticFiles): """Static assets that must be revalidated, never served blind from cache. The SPA is a module graph that has to agree with itself: `app.js` imports `i18n.js`, which imports a catalogue from `locales/`. Serving one of them from cache while fetching another is not a stale page, it is a broken one — a browser holding the previous `i18n.js` fails to link the new `app.js` ("does not provide an export named 'initLocale'") before any code runs, and the reverse pairing renders every string as its own key. Starlette sends only `etag` and `last-modified`, and with no explicit freshness a browser is entitled to guess one. `no-cache` does not disable caching: it requires a conditional request, which the existing ETag answers with a 304 and no body. """ async def get_response(self, path, scope): response = await super().get_response(path, scope) response.headers.setdefault("Cache-Control", "no-cache") return response class VersionedStatics(StaticFiles): """The same files under a URL that changes when they do. `no-cache` above only binds a browser that asks. One that cached the SPA before that header existed applies heuristic freshness and does not ask at all, so it runs an old player against a new node — a fix that is deployed, served, and not running, which looks exactly like a fix that does not work. Measured: a phone kept a player without the read-ahead bound and filled the browser's buffer ceiling at 106 MB, while the server had been serving the bounded one for an hour. Serving the graph under /a// solves it for every file at once, because relative imports inherit the prefix: `app.js` reaching for `./i18n.js` gets the version it was built against, and never a mixture. The URL changes with the content, so these may be cached hard. """ async def get_response(self, path, scope): response = await super().get_response(path, scope) response.headers["Cache-Control"] = ( "public, max-age=31536000, immutable") return response # Before "/", which would otherwise swallow it. app.mount(f"/a/{ASSET_V}", VersionedStatics(directory=STATIC_DIR), name="static-versioned") # Still served unversioned: sw.js must stay at the root or its scope stops # covering the pages it intercepts downloads for, and old bookmarks of # /style.css and the like should not 404. app.mount("/", RevalidatingStatics(directory=STATIC_DIR), name="static") return app