"""FastAPI application: middlewares, routers, static frontend, background maintenance.""" from __future__ import annotations import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware from app.api.v1 import api_router from app.core.config import get_settings from app.core.logging import configure_logging, get_logger from app.db import init_db from app.rag import retriever from app.services import conversations as conv_service from app.services import courses as course_service from app.services import files as file_service settings = get_settings() configure_logging(settings.LOG_LEVEL, json_output=not settings.is_dev) log = get_logger("main") async def _maintenance() -> None: while True: try: n = await file_service.purge_expired() r = await conv_service.redact_old_messages(settings.MESSAGE_RETENTION_MONTHS) if n or r: log.info("maintenance", purged_files=n, redacted_messages=r) except Exception as exc: # noqa: BLE001 log.warning("maintenance_failed", error=str(exc)) await asyncio.sleep(3600) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: await init_db() await course_service.seed_courses() import app.tools.all # noqa: F401 — register tools n = await retriever.rebuild_index() log.info("startup", chunks=n, env=settings.APP_ENV, port=settings.PORT) task = asyncio.create_task(_maintenance()) yield task.cancel() app = FastAPI(title="UQO-Chat API", version="0.1.0", lifespan=lifespan, docs_url="/api/docs" if settings.is_dev else None, redoc_url=None, openapi_url="/api/openapi.json" if settings.is_dev else None) app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) class SecurityHeaders(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # noqa: ANN001, ANN201 resp = await call_next(request) resp.headers.setdefault("X-Content-Type-Options", "nosniff") resp.headers.setdefault("X-Frame-Options", "DENY") resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") resp.headers.setdefault("Permissions-Policy", "camera=(), geolocation=()") if not settings.is_dev: resp.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains") if request.url.path == "/" or request.url.path.endswith(".html") or \ "text/html" in resp.headers.get("content-type", ""): resp.headers.setdefault( "Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob: https:; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self' data: https://fonts.gstatic.com; " "script-src 'self'; connect-src 'self'; worker-src 'self'; " "frame-ancestors 'none'; base-uri 'self'; form-action 'self'") return resp app.add_middleware(SecurityHeaders) app.include_router(api_router) @app.exception_handler(429) async def _rate_limited(_: Request, exc): # noqa: ANN001, ANN201 return JSONResponse(status_code=429, content={"detail": getattr(exc, "detail", "Trop de requêtes.")}) # ---- Frontend (built SPA) --------------------------------------------------------------- dist: Path = settings.FRONTEND_DIST if dist.exists(): app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets") @app.api_route("/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False) async def spa(full_path: str): # noqa: ANN201 candidate = dist / full_path if full_path and candidate.is_file(): return FileResponse(candidate) return FileResponse(dist / "index.html") else: @app.get("/", include_in_schema=False) async def root() -> dict: return {"service": "uqo-chat", "docs": "/api/docs", "frontend": "not built"}