SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
4.4 KB · 109 lines python
Raw Blame History
1"""FastAPI application: middlewares, routers, static frontend, background maintenance."""23from __future__ import annotations45import asyncio6from collections.abc import AsyncIterator7from contextlib import asynccontextmanager8from pathlib import Path910from fastapi import FastAPI, Request11from fastapi.middleware.cors import CORSMiddleware12from fastapi.responses import FileResponse, JSONResponse13from fastapi.staticfiles import StaticFiles14from starlette.middleware.base import BaseHTTPMiddleware1516from app.api.v1 import api_router17from app.core.config import get_settings18from app.core.logging import configure_logging, get_logger19from app.db import init_db20from app.rag import retriever21from app.services import conversations as conv_service22from app.services import courses as course_service23from app.services import files as file_service2425settings = get_settings()26configure_logging(settings.LOG_LEVEL, json_output=not settings.is_dev)27log = get_logger("main")282930async def _maintenance() -> None:31    while True:32        try:33            n = await file_service.purge_expired()34            r = await conv_service.redact_old_messages(settings.MESSAGE_RETENTION_MONTHS)35            if n or r:36                log.info("maintenance", purged_files=n, redacted_messages=r)37        except Exception as exc:  # noqa: BLE00138            log.warning("maintenance_failed", error=str(exc))39        await asyncio.sleep(3600)404142@asynccontextmanager43async def lifespan(_: FastAPI) -> AsyncIterator[None]:44    await init_db()45    await course_service.seed_courses()46    import app.tools.all  # noqa: F401 — register tools4748    n = await retriever.rebuild_index()49    log.info("startup", chunks=n, env=settings.APP_ENV, port=settings.PORT)50    task = asyncio.create_task(_maintenance())51    yield52    task.cancel()535455app = FastAPI(title="UQO-Chat API", version="0.1.0", lifespan=lifespan,56              docs_url="/api/docs" if settings.is_dev else None, redoc_url=None,57              openapi_url="/api/openapi.json" if settings.is_dev else None)5859app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True,60                   allow_methods=["*"], allow_headers=["*"])616263class SecurityHeaders(BaseHTTPMiddleware):64    async def dispatch(self, request: Request, call_next):  # noqa: ANN001, ANN20165        resp = await call_next(request)66        resp.headers.setdefault("X-Content-Type-Options", "nosniff")67        resp.headers.setdefault("X-Frame-Options", "DENY")68        resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")69        resp.headers.setdefault("Permissions-Policy", "camera=(), geolocation=()")70        if not settings.is_dev:71            resp.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")72        if request.url.path == "/" or request.url.path.endswith(".html") or \73                "text/html" in resp.headers.get("content-type", ""):74            resp.headers.setdefault(75                "Content-Security-Policy",76                "default-src 'self'; img-src 'self' data: blob: https:; "77                "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "78                "font-src 'self' data: https://fonts.gstatic.com; "79                "script-src 'self'; connect-src 'self'; worker-src 'self'; "80                "frame-ancestors 'none'; base-uri 'self'; form-action 'self'")81        return resp828384app.add_middleware(SecurityHeaders)85app.include_router(api_router)868788@app.exception_handler(429)89async def _rate_limited(_: Request, exc):  # noqa: ANN001, ANN20190    return JSONResponse(status_code=429, content={"detail": getattr(exc, "detail",91                                                                     "Trop de requêtes.")})929394# ---- Frontend (built SPA) ---------------------------------------------------------------95dist: Path = settings.FRONTEND_DIST96if dist.exists():97    app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets")9899    @app.api_route("/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False)100    async def spa(full_path: str):  # noqa: ANN201101        candidate = dist / full_path102        if full_path and candidate.is_file():103            return FileResponse(candidate)104        return FileResponse(dist / "index.html")105else:106    @app.get("/", include_in_schema=False)107    async def root() -> dict:108        return {"service": "uqo-chat", "docs": "/api/docs", "frontend": "not built"}109