The app had no cleanup of any kind: in multi-user mode every first visit mints a users row, so the demo has been accumulating one permanent account per visitor along with everything they generated. cleanup.py sweeps guests idle for AIDND_GUEST_RETENTION_DAYS (default 5), once at startup and then every few hours. Startup is the load-bearing trigger — the free tier sleeps after ~15 minutes, so a long timer rarely gets to fire. Idle is COALESCE(last_seen_at, created_at), not last_seen_at: _touch only writes that column hourly, and a guest minted by /auth/me has it NULL until its second request, so the simpler query would have deleted brand-new visitors mid-session. It's one Core DELETE rather than db.delete(user), which would SELECT every adventure, action and memory into Python purely to delete them — the same egress pattern as the 189x fix. Every FK from users down is ON DELETE CASCADE, so the database does the whole graph and returns a count. The filter requires is_guest AND email IS NULL, so registered users (who upgrade in place) and local mode's implicit user are both out of reach, and is_public is output-only so a guest can never own content another user can see. Session cookies have no expiry and can outlive a swept row; that path 401s and the frontend's existing retry re-mints a session. Guests are told: /auth/me serves guest_retention_days and the signup modal states the window, sourced from the server so it can't drift from what is enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CYEJKobJ2Re4Dv7qUoSA7
135 lines
4.3 KiB
Python
135 lines
4.3 KiB
Python
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 cleanup
|
|
from .auth import MULTI_USER
|
|
from .database import engine
|
|
from .limits import BodySizeLimitMiddleware
|
|
from .migrations import bootstrap
|
|
from .routers import adventures, 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()
|
|
try:
|
|
yield
|
|
finally:
|
|
await cleanup.stop_sweeper(sweeper)
|
|
|
|
|
|
# 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: same-origin everything, inline styles (React), Google Fonts."""
|
|
|
|
_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' https://fonts.googleapis.com; "
|
|
b"font-src https://fonts.gstatic.com; "
|
|
b"img-src 'self' data:; "
|
|
b"connect-src '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)
|
|
|
|
|
|
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.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
|
|
|
|
|
|
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")
|