v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""Фабрика приложения 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,
|
||||
)
|
||||
|
||||
if 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_auth/dev_stub исключены, импорт просто не выполнится).
|
||||
if settings.is_development:
|
||||
try:
|
||||
from app.routers import dev_auth
|
||||
|
||||
app.include_router(dev_auth.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()
|
||||
Reference in New Issue
Block a user