main.py: openapi.json/docs/redoc отдаются только в dev/test (нужны для gen:api), в production отключены. Caddyfile (edge): HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, скрыт Server; CSP подготовлена, но выключена до проверки на test-клоне (строгая политика ломает SPA/Telegram-виджет/SSE). #61 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""Фабрика приложения 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 при старте
|
||
# (в 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
|
||
)
|
||
|
||
purge_task = asyncio.create_task(_notifications_purge_loop())
|
||
try:
|
||
yield
|
||
finally:
|
||
purge_task.cancel()
|
||
|
||
|
||
def create_app() -> FastAPI:
|
||
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
|
||
# `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).
|
||
# В 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:
|
||
# Только 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
|
||
# и только если код физически есть (в 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()
|
||
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()
|