"""Фабрика приложения 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 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: """Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем совпадения заголовка X-CSRF-Token и cookie csrf_token. Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE (/api/events). Чистый ASGI пропускает стримы насквозь, вмешиваясь только при отказе CSRF. """ def __init__(self, app) -> None: # noqa: ANN001 self.app = app async def __call__(self, scope, receive, send): # noqa: ANN001 if scope["type"] == "http": request = Request(scope) if request.method in _UNSAFE_METHODS and request.url.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: response = JSONResponse( status_code=403, content={ "error": { "code": "CSRF_FAILED", "message": "Неверный или отсутствующий CSRF-токен.", "details": None, } }, ) await response(scope, receive, send) return await self.app(scope, receive, send) @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()