220 lines
7.7 KiB
Python
220 lines
7.7 KiB
Python
import asyncio
|
|
import hashlib
|
|
import os
|
|
from time import perf_counter
|
|
|
|
from fastapi import Depends, FastAPI, Request
|
|
from fastapi.responses import ORJSONResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.middleware.cors import CORSMiddleware
|
|
from starlette.middleware.gzip import GZipMiddleware
|
|
from starlette.responses import Response as StarletteResponse
|
|
from starlette.staticfiles import StaticFiles
|
|
|
|
from audit import ensure_api_context, log_api_access, record_api_access_event_background
|
|
from config import API_LOGGING, API_VERSION, API_CORS_ORIGINS
|
|
from database import async_session_maker
|
|
from logger import logger
|
|
|
|
if API_VERSION == 1:
|
|
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
|
|
else:
|
|
from api.v2 import VERSION as API_DOC_VERSION
|
|
from api.v2.router import router as api_router
|
|
|
|
app = FastAPI(
|
|
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
|
|
version=API_DOC_VERSION,
|
|
description=f"Версия API: **v{API_DOC_VERSION}**.",
|
|
docs_url="/api/docs",
|
|
redoc_url="/api/redoc",
|
|
openapi_url="/api/openapi.json",
|
|
default_response_class=ORJSONResponse,
|
|
)
|
|
|
|
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
|
|
_cors_credentials = API_CORS_ORIGINS != ["*"]
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_cors_origins,
|
|
allow_credentials=_cors_credentials,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
|
|
)
|
|
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def _generic_exception_handler(request: Request, exc: Exception):
|
|
from audit import ensure_api_context
|
|
|
|
context = ensure_api_context(request)
|
|
logger.exception("[API] Unhandled exception at {} {}: {}", request.method, request.url.path, exc)
|
|
return ORJSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Внутренняя ошибка сервера", "request_id": context.request_id},
|
|
)
|
|
|
|
|
|
_ETAG_MAX_BODY_BYTES = 256 * 1024
|
|
|
|
|
|
@app.middleware("http")
|
|
async def security_and_cache_middleware(request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
|
|
|
content_type = response.headers.get("content-type", "")
|
|
path = request.url.path
|
|
|
|
if path.startswith("/api/web/uploads/") and request.method == "GET" and response.status_code == 200:
|
|
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
|
|
return response
|
|
|
|
if request.method == "GET" and response.status_code == 200 and "application/json" in content_type:
|
|
content_length_header = response.headers.get("content-length")
|
|
try:
|
|
cl = int(content_length_header) if content_length_header is not None else None
|
|
except (TypeError, ValueError):
|
|
cl = None
|
|
if cl is not None and cl > _ETAG_MAX_BODY_BYTES:
|
|
response.headers.setdefault("Cache-Control", "no-cache")
|
|
return response
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
too_big = False
|
|
async for chunk in response.body_iterator:
|
|
total += len(chunk)
|
|
if total > _ETAG_MAX_BODY_BYTES:
|
|
chunks.append(chunk)
|
|
too_big = True
|
|
async for remaining in response.body_iterator:
|
|
chunks.append(remaining)
|
|
break
|
|
chunks.append(chunk)
|
|
body = b"".join(chunks)
|
|
if too_big:
|
|
headers = dict(response.headers)
|
|
headers["Cache-Control"] = "no-cache"
|
|
headers.pop("content-length", None)
|
|
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
|
etag = '"' + hashlib.md5(body).hexdigest() + '"'
|
|
if_none_match = request.headers.get("if-none-match", "")
|
|
client_etags = [t.strip() for t in if_none_match.split(",") if t.strip()]
|
|
if etag in client_etags or if_none_match.strip() == "*":
|
|
return StarletteResponse(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
|
headers = dict(response.headers)
|
|
headers["ETag"] = etag
|
|
headers["Cache-Control"] = "no-cache"
|
|
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
|
|
|
response.headers.setdefault("Cache-Control", "no-store")
|
|
return response
|
|
|
|
|
|
@app.middleware("http")
|
|
async def api_access_log_middleware(request: Request, call_next):
|
|
context = ensure_api_context(request)
|
|
started = perf_counter()
|
|
if not API_LOGGING:
|
|
response = await call_next(request)
|
|
response.headers["X-Request-Id"] = context.request_id
|
|
response.headers["X-Response-Time"] = f"{int((perf_counter() - started) * 1000)}ms"
|
|
return response
|
|
|
|
try:
|
|
response = await call_next(request)
|
|
except Exception as exc:
|
|
duration_ms = int((perf_counter() - started) * 1000)
|
|
log_api_access(
|
|
request,
|
|
status_code=500,
|
|
duration_ms=duration_ms,
|
|
result="fail",
|
|
reason=type(exc).__name__,
|
|
)
|
|
asyncio.create_task(
|
|
record_api_access_event_background(
|
|
async_session_maker,
|
|
request,
|
|
result="fail",
|
|
reason=type(exc).__name__,
|
|
status_code=500,
|
|
)
|
|
)
|
|
raise
|
|
|
|
duration_ms = int((perf_counter() - started) * 1000)
|
|
response.headers["X-Request-Id"] = context.request_id
|
|
response.headers["X-Response-Time"] = f"{duration_ms}ms"
|
|
result = "success" if response.status_code < 400 else "fail"
|
|
log_api_access(
|
|
request,
|
|
status_code=response.status_code,
|
|
duration_ms=duration_ms,
|
|
result=result,
|
|
)
|
|
asyncio.create_task(
|
|
record_api_access_event_background(
|
|
async_session_maker,
|
|
request,
|
|
result=result,
|
|
reason=None if response.status_code < 400 else str(response.status_code),
|
|
status_code=response.status_code,
|
|
)
|
|
)
|
|
return response
|
|
|
|
|
|
@app.get("/api/health", include_in_schema=False)
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
from api.depends import get_session as _get_session, verify_identity_admin as _verify_admin
|
|
|
|
|
|
@app.get("/api/health/detailed", include_in_schema=False)
|
|
async def health_detailed(
|
|
session: AsyncSession = Depends(_get_session),
|
|
_identity=Depends(_verify_admin),
|
|
):
|
|
import time
|
|
from sqlalchemy import text
|
|
from core.redis_cache import _get_redis
|
|
|
|
checks: dict[str, object] = {"status": "ok", "timestamp": int(time.time())}
|
|
|
|
try:
|
|
await session.execute(text("SELECT 1"))
|
|
checks["db"] = {"ok": True}
|
|
except Exception as e:
|
|
checks["db"] = {"ok": False, "error": str(e)[:200]}
|
|
checks["status"] = "degraded"
|
|
|
|
try:
|
|
client = await _get_redis()
|
|
if client is not None:
|
|
await client.ping()
|
|
checks["redis"] = {"ok": True}
|
|
else:
|
|
checks["redis"] = {"ok": False, "error": "unavailable"}
|
|
checks["status"] = "degraded"
|
|
except Exception as e:
|
|
checks["redis"] = {"ok": False, "error": str(e)[:200]}
|
|
checks["status"] = "degraded"
|
|
|
|
return checks
|
|
|
|
|
|
app.include_router(api_router)
|
|
|
|
_web_uploads_dir = "static/web_uploads"
|
|
os.makedirs(_web_uploads_dir, exist_ok=True)
|
|
app.mount("/api/web/uploads", StaticFiles(directory=_web_uploads_dir), name="web_uploads")
|