Files
interactive-story/backend/app/main.py
T
parththakkar106andClaude Opus 4.8 c96b8cdf2f Seed public demo scenarios on startup
Idempotent seeder inserts public, unowned demo scenarios from
backend/app/seed_data/*.json if a public scenario with the same title
is missing, so a fresh Neon database (and any clone) gets demo content
guests can play immediately. Ships two scenarios: the Sunken Crypt of
Vharos (all four script hooks) and Signal from the Derelict (sci-fi,
context-engine focused).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
2026-07-20 16:17:57 +05:30

119 lines
3.8 KiB
Python

import os
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 .auth import MULTI_USER
from .database import engine
from .limits import BodySizeLimitMiddleware
from .migrations import bootstrap
from .routers import adventures, auth, 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"]
# 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",
)
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(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")