"""Фабрика приложения 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, notifications, 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"} def _with_fresh_csrf_cookie(send): # noqa: ANN001, ANN202 """Дописывает свежий csrf_token в заголовки ответа. Трогает только http.response.start: тело (в т.ч. SSE-поток) проходит насквозь, чанк за чанком.""" async def wrapped(message): # noqa: ANN001 if message["type"] == "http.response.start": headers = list(message.get("headers", [])) already_set = any( k.lower() == b"set-cookie" and v.startswith(security.CSRF_COOKIE.encode() + b"=") for k, v in headers ) if not already_set: headers.append(security.fresh_csrf_set_cookie()) message = {**message, "headers": headers} await send(message) return wrapped class CSRFMiddleware: """Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем совпадения заголовка X-CSRF-Token и cookie csrf_token. Если сессия есть, а csrf_token в запросе нет (cookie истекла или её стёрли), любой ответ на /api — включая отказ ниже — перевыдаёт токен. Иначе состояние не лечилось: токен выдаётся только при входе, а войти и выйти мешала эта же проверка. Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE (/api/events). Чистый ASGI пропускает стримы насквозь. """ 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.url.path.startswith("/api"): has_session = ( security.USER_COOKIE in request.cookies or security.ADMIN_COOKIE in request.cookies ) cookie_token = request.cookies.get(security.CSRF_COOKIE) if has_session and not cookie_token: send = _with_fresh_csrf_cookie(send) if has_session and request.method in _UNSAFE_METHODS: 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) _NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления async def _notifications_purge_loop() -> None: """Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении).""" import asyncio from app.db.session import Session, engine from app.services import notification_service def _purge_once() -> None: with Session(engine) as session: notification_service.purge_expired(session) while True: try: await asyncio.sleep(_NOTIFICATIONS_PURGE_INTERVAL) await asyncio.to_thread(_purge_once) except asyncio.CancelledError: break except Exception as exc: # noqa: BLE001 logging.getLogger("fs").warning("Чистка уведомлений пропущена: %s", exc) @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 при старте # (в 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 ) purge_task = asyncio.create_task(_notifications_purge_loop()) try: yield finally: purge_task.cancel() def create_app() -> FastAPI: # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для # `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем — # незачем облегчать разведку поверхности API анонимам (#61). docs_enabled = not settings.is_production app = FastAPI( title="Forbidden Stars API", version="0.1.0", openapi_url="/api/openapi.json" if docs_enabled else None, docs_url="/api/docs" if docs_enabled else None, redoc_url="/api/redoc" if docs_enabled else None, lifespan=_lifespan, ) # CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin). # В 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: # Только type/loc/msg. В input лежит тело запроса: эхо паролей в ответ, а для тела # не в JSON (text/plain от HTML-формы) — сырые bytes, которые JSON не сериализует, # и ответ падал в 500. В ctx бывают объекты исключений — та же проблема. details = [{"type": e["type"], "loc": e["loc"], "msg": e["msg"]} for e in exc.errors()] return JSONResponse( status_code=422, content={ "error": { "code": "VALIDATION_ERROR", "message": "Ошибка валидации запроса.", "details": details, } }, ) # API-роутеры под /api. api_routers = [auth.router, users.router, groups.router, invitations.router, matches.router, reference.router, stats.router, achievements.router, events.router, notifications.router, admin.router] for r in api_routers: app.include_router(r, prefix="/api") # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development # и только если код физически есть (в прод-образе 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() and candidate.name != "index.html" ): # Хэшированные ассеты Vite — обычное кэширование (имя меняется при сборке). return FileResponse(candidate) # index.html (корень и SPA-маршруты) — НЕ кэшируем: иначе браузер отдаёт старый # документ из кэша и при недоступности хоста не доходит до Caddy с заглушкой. return FileResponse(index_file, headers={"Cache-Control": "no-store"}) app = create_app()