"""Фабрика приложения FastAPI: API под /api + отдача собранного SPA.""" from __future__ import annotations import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from app.core import security from app.core.config import settings from app.core.errors import AppError, app_error_handler from app.routers import ( achievements, admin, auth, events, groups, invitations, matches, reference, stats, users, ) # Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать). _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static"))) _UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} class CSRFMiddleware(BaseHTTPMiddleware): """Double-submit CSRF: для аутентифицированных мутаций на /api требуем совпадения заголовка X-CSRF-Token и cookie csrf_token.""" async def dispatch(self, request: Request, call_next): # noqa: ANN001 path = request.url.path if request.method in _UNSAFE_METHODS and path.startswith("/api"): has_session = ( security.USER_COOKIE in request.cookies or security.ADMIN_COOKIE in request.cookies ) if has_session: cookie_token = request.cookies.get(security.CSRF_COOKIE) header_token = request.headers.get(security.CSRF_HEADER) if not cookie_token or cookie_token != header_token: return JSONResponse( status_code=403, content={ "error": { "code": "CSRF_FAILED", "message": "Неверный или отсутствующий CSRF-токен.", "details": None, } }, ) return await call_next(request) @asynccontextmanager async def _lifespan(_app: FastAPI): # SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения). import asyncio from app.core.events import hub hub.bind_loop(asyncio.get_running_loop()) # В DEV приложение само подтягивает справочники и админа из .env при старте # (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0": try: from app.bootstrap import bootstrap bootstrap() except Exception as exc: # noqa: BLE001 logging.getLogger("fs").warning( "Стартовый bootstrap пропущен (примените миграции): %s", exc ) yield def create_app() -> FastAPI: app = FastAPI( title="Forbidden Stars API", version="0.1.0", openapi_url="/api/openapi.json", docs_url="/api/docs", redoc_url="/api/redoc", lifespan=_lifespan, ) # CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin). # В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем. if settings.is_development and settings.cors_origins_list: app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(CSRFMiddleware) # Обработчики ошибок → единый конверт. app.add_exception_handler(AppError, app_error_handler) @app.exception_handler(RequestValidationError) async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse: return JSONResponse( status_code=422, content={ "error": { "code": "VALIDATION_ERROR", "message": "Ошибка валидации запроса.", "details": exc.errors(), } }, ) # API-роутеры под /api. api_routers = [auth.router, users.router, groups.router, invitations.router, matches.router, reference.router, stats.router, achievements.router, events.router, admin.router] for r in api_routers: app.include_router(r, prefix="/api") # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development # и только если код физически есть (в test/prod-образе dev_*-файлы исключены # .dockerignore, импорт просто не выполнится). if settings.is_development: for mod_name in ("dev_auth", "dev_admin"): try: mod = __import__(f"app.routers.{mod_name}", fromlist=["router"]) app.include_router(mod.router, prefix="/api") except ImportError: pass @app.get("/api/health", tags=["meta"]) def health() -> dict: return {"status": "ok"} _mount_spa(app) return app def _mount_spa(app: FastAPI) -> None: """Отдаём собранный SPA: статика + fallback на index.html для client-routes.""" index_file = _STATIC_DIR / "index.html" if not index_file.exists(): return # в dev фронт обслуживает Vite на :5173 @app.get("/{full_path:path}", include_in_schema=False) async def spa(full_path: str): # noqa: ANN202 # Неизвестный API-путь — это 404 (JSON), а не отдача SPA. if full_path == "api" or full_path.startswith("api/"): return JSONResponse( status_code=404, content={"error": {"code": "NOT_FOUND", "message": "Не найдено.", "details": None}}, ) candidate = (_STATIC_DIR / full_path).resolve() if ( full_path and _STATIC_DIR in candidate.parents and candidate.is_file() ): return FileResponse(candidate) return FileResponse(index_file) app = create_app()