import mimetypes import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from . import analytics, cleanup from .auth import MULTI_USER from .database import engine from .limits import BodySizeLimitMiddleware from .migrations import bootstrap from .routers import ( adventures, analytics as analytics_router, auth, chat, debug, scenarios, scripts, settings, story_cards, ) from .seed import seed_public_scenarios bootstrap(engine) seed_public_scenarios(engine) # Production serves the SPA same-origin, so CORS only matters for the Vite dev # server; AIDND_CORS_ORIGINS overrides for any other cross-origin setup. CORS_ORIGINS = [ o.strip() for o in os.environ.get("AIDND_CORS_ORIGINS", "").split(",") if o.strip() ] or ["http://localhost:5173", "http://127.0.0.1:5173"] @asynccontextmanager async def lifespan(_app: FastAPI): # Sweeps once on boot, then on an interval. Booting is the reliable # trigger on Render's free tier, where the service sleeps after ~15 # minutes and a long-running timer rarely gets to fire. sweeper = cleanup.start_sweeper() # Visit counters are buffered in memory and written in batches; this is # what turns them into rows, and stop_flusher writes out the last batch so # a deploy doesn't drop it. flusher = analytics.start_flusher() try: yield finally: await cleanup.stop_sweeper(sweeper) await analytics.stop_flusher(flusher) # The interactive API docs stay local-only: in multi-user mode they just hand # strangers a map of the API surface. app = FastAPI( title="AI D&D", docs_url=None if MULTI_USER else "/docs", redoc_url=None, openapi_url=None if MULTI_USER else "/openapi.json", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(BodySizeLimitMiddleware) class SecurityHeadersMiddleware: """Standard hardening headers on every response. Pure ASGI (wraps `send`) so SSE streams pass through unbuffered. The CSP allows exactly what the SPA uses, and that is now same-origin and nothing else: scripts, styles, fonts, images and XHR/SSE all resolve to the app itself. The fonts used to come from Google, which made an Internet request on every page load; they are self-hosted under /fonts/ instead (frontend/tools/vendor_fonts.py), so `font-src 'self'` covers them and the two remote hosts are gone from the policy. `'unsafe-inline'` stays on `style-src` because React writes inline `style` attributes. It is deliberately absent from `script-src`. """ _HEADERS = [ (b"x-content-type-options", b"nosniff"), (b"referrer-policy", b"same-origin"), (b"x-frame-options", b"DENY"), ( b"content-security-policy", b"default-src 'self'; " b"script-src 'self'; " b"style-src 'self' 'unsafe-inline'; " b"font-src 'self'; " b"img-src 'self' data:; " b"connect-src 'self'; " b"object-src 'none'; " b"base-uri 'none'; " b"form-action 'self'; " b"frame-ancestors 'none'", ), ] def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": return await self.app(scope, receive, send) async def send_with_headers(message): if message["type"] == "http.response.start": message.setdefault("headers", []) message["headers"] = list(message["headers"]) + self._HEADERS await send(message) await self.app(scope, receive, send_with_headers) class ApiErrorMiddleware: """Counts failed API responses for the analytics dashboard. This is middleware rather than an exception handler, because it observes what the client received. A 429 from a rate limiter, a 404 from routing, and a 500 from a handler that never returned all reach it the same way. It is pure ASGI for the same reason as the headers above: an SSE turn must not be buffered on its way out. It watches `/api` only, because a 404 on the SPA mount is a page load rather than a fault. """ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http" or not scope.get("path", "").startswith("/api"): return await self.app(scope, receive, send) async def send_counting(message): if message["type"] == "http.response.start" and message["status"] >= 400: # The router has already put the matched route on the scope by # the time a response starts, so the label can name the # endpoint rather than the caller's path. analytics.record( analytics.M_ERROR, analytics.api_route_label(scope, message["status"]), ) await send(message) await self.app(scope, receive, send_counting) app.add_middleware(ApiErrorMiddleware) app.add_middleware(SecurityHeadersMiddleware) app.include_router(auth.router) app.include_router(scenarios.router) app.include_router(adventures.router) app.include_router(story_cards.router) app.include_router(scripts.router) app.include_router(settings.router) app.include_router(chat.router) app.include_router(debug.router) app.include_router(analytics_router.router) @app.get("/api/health") def health(): return {"ok": True} # In production, serve the built frontend (frontend/dist) as static files. class SPAStaticFiles(StaticFiles): """Serve index.html for unknown paths so client-side routes (/play/3) survive a page reload. API routes are matched before this mount.""" async def get_response(self, path, scope): try: response = await super().get_response(path, scope) except StarletteHTTPException as exc: if exc.status_code != 404: raise return await super().get_response("index.html", scope) if response.status_code == 404: return await super().get_response("index.html", scope) return response # Python's mimetypes table has no entry for woff2 on a slim Debian image, so # StaticFiles served the self-hosted fonts as application/octet-stream. Browsers # take them anyway — a @font-face src carries its own format() hint — but the # honest type costs one line. mimetypes.add_type("font/woff2", ".woff2") mimetypes.add_type("font/woff", ".woff") frontend_dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" if frontend_dist.is_dir(): app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend")