"""FastAPI-Anwendung. Ein einzelner Prozess liefert sowohl die JSON-API unter ``/api/*`` als auch das gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutzers. Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt. """ from contextlib import asynccontextmanager from pathlib import Path from fastapi import APIRouter, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from .branding import APP_NAME, VERSION, git_sha from .config import get_settings from .database import SessionLocal from .migrate import upgrade_to_head from .routers import auth, settings as settings_router, users from .seed import ensure_first_admin settings = get_settings() # Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier). REPO_ROOT = Path(__file__).resolve().parent.parent.parent DIST_DIR = REPO_ROOT / "web" / "dist" @asynccontextmanager async def lifespan(app: FastAPI): # Schema auf den aktuellen Stand bringen (idempotent). upgrade_to_head() db = SessionLocal() try: ensure_first_admin(db) finally: db.close() yield app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan) # CORS nur, wenn ausdrücklich konfiguriert (Standard: gleiche Herkunft, aus). if settings.cors_origins.strip(): origins = ( ["*"] if settings.cors_origins.strip() == "*" else [o.strip() for o in settings.cors_origins.split(",") if o.strip()] ) app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- API ------------------------------------------------------------------- api = APIRouter(prefix="/api") @api.get("/health", tags=["meta"]) def health() -> dict: return {"status": "ok"} @api.get("/version", tags=["meta"]) def version() -> dict: return {"name": APP_NAME, "version": VERSION, "git_sha": git_sha()} api.include_router(auth.router) api.include_router(users.router) api.include_router(settings_router.router) app.include_router(api) # --- Frontend (SPA) -------------------------------------------------------- # Gebaute, gehashte Assets unter /assets aggressiv cachebar ausliefern; alle # übrigen (nicht-/api) Pfade auf index.html abbilden, damit Client-seitige # Routen wie /accounts direkt aufrufbar sind (verlinkbar). if DIST_DIR.exists(): assets_dir = DIST_DIR / "assets" if assets_dir.exists(): app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") @app.get("/{full_path:path}", include_in_schema=False) async def spa(full_path: str): # /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA). if full_path.startswith("api"): raise HTTPException(status_code=404, detail="Not Found") candidate = DIST_DIR / full_path if full_path and candidate.is_file(): return FileResponse(candidate) return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})