import asyncio import contextlib from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import text import app.models # noqa: F401 — registers models on Base.metadata before create_all from app.config import settings from app.db import Base, async_session_maker, engine from app.routes.auth import router as auth_router from app.routes.codex import router as codex_router from app.routes.device import router as device_router from app.routes.inventory import router as inventory_router from app.routes.shop import router as shop_router from app.session_cleanup import delete_expired_sessions from app.ws import AUDIO_DIR from app.ws import router as ws_router FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60 async def _session_cleanup_loop() -> None: """Periodically sweeps expired auth_sessions rows so the table doesn't grow forever — get_current_user already rejects expired sessions on read, this just deletes the rows themselves.""" try: while True: await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS) try: async with async_session_maker() as db: await delete_expired_sessions(db) except Exception: # A transient DB hiccup shouldn't kill the sweep loop — # just try again next interval. pass except asyncio.CancelledError: pass @asynccontextmanager async def lifespan(app: FastAPI): AUDIO_DIR.mkdir(parents=True, exist_ok=True) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # No Alembic in this repo — `create_all` never alters existing # tables, so columns added to live models need a manual, idempotent # migration here. Safe to run on every startup. await conn.execute(text( "ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb" )) cleanup_task = asyncio.create_task(_session_cleanup_loop()) try: yield finally: cleanup_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cleanup_task app = FastAPI(title="Quantumancy", lifespan=lifespan) app.include_router(auth_router) app.include_router(codex_router) app.include_router(device_router) app.include_router(inventory_router) app.include_router(shop_router) app.include_router(ws_router) @app.get("/healthz") async def healthz(): return {"status": "ok"} app.mount( "/assets", StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False), name="frontend-assets", ) app.mount( "/audio", StaticFiles(directory=AUDIO_DIR, check_dir=False), name="spirit-audio", ) @app.get("/{full_path:path}") async def serve_spa(full_path: str): index_file = FRONTEND_DIST / "index.html" if not index_file.exists(): raise HTTPException( status_code=503, detail="Frontend not built. Run `npm run build` in frontend/ and restart.", ) # Vite emits root-level static files (favicon.ico, favicon.svg, # apple-touch-icon.png, og-image.png, …) straight into dist/ rather than # dist/assets/ — the only mounted static dir. Without this, requests for # them fall through to the SPA fallback below and get index.html back # instead of the actual file (browsers silently ignore it; social-media # link-preview crawlers fetching og:image get an HTML page). if full_path: dist_root = FRONTEND_DIST.resolve() candidate = (dist_root / full_path).resolve() if candidate.is_file() and dist_root in candidate.parents: return FileResponse(candidate) return FileResponse(index_file)