Files
ForbiddenStarsApp/backend/app/main.py
T

158 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Фабрика приложения 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 admin, auth, groups, 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):
# В 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, matches.router,
reference.router, stats.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()