FluxIP/app/main.py

214 lines
8.4 KiB
Python

from __future__ import annotations
import logging
import uuid
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 fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.responses import Response
from app import __version__
from app.accounts.router import router as accounts_router
from app.auth.router import router as auth_router
from app.container import AppContainer
from app.core.config import AppSettings, get_settings
from app.core.errors import AppError
from app.core.logging import configure_logging
from app.core.network import request_is_https
from app.dashboard.router import router as dashboard_router
from app.fleet.router import router as fleet_router
from app.integrations.router import router as integrations_router
from app.rotation.router import router as rotation_router
logger = logging.getLogger(__name__)
STATIC_DIR = Path(__file__).parent / "static"
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-Id"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self'; "
"img-src 'self' data:; connect-src 'self'; object-src 'none'; "
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
)
if request_is_https(request, request.app.state.settings):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
logger.info(
"request_completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
},
)
return response
class OriginValidationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.method not in {"GET", "HEAD", "OPTIONS"}:
origin = request.headers.get("Origin")
if origin:
scheme = (
"https"
if request_is_https(request, request.app.state.settings)
else request.url.scheme
)
expected = f"{scheme}://{request.url.netloc}"
allowed = request.app.state.settings.origins
if origin != expected and origin not in allowed:
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
return JSONResponse(
status_code=403,
content={
"type": "about:blank#origin-rejected",
"title": "请求已拒绝",
"status": 403,
"detail": "请求来源校验失败",
"request_id": request_id,
},
)
return await call_next(request)
def create_app(settings: AppSettings | None = None) -> FastAPI:
resolved_settings = settings or get_settings()
configure_logging(resolved_settings.log_level)
@asynccontextmanager
async def lifespan(app: FastAPI):
container = AppContainer(resolved_settings)
app.state.container = container
await container.startup()
try:
yield
finally:
await container.shutdown()
application = FastAPI(
title="FluxIP API",
version=__version__,
docs_url="/api/docs" if resolved_settings.environment != "production" else None,
redoc_url=None,
lifespan=lifespan,
)
application.state.settings = resolved_settings
application.add_middleware(RequestContextMiddleware)
application.add_middleware(OriginValidationMiddleware)
application.add_middleware(TrustedHostMiddleware, allowed_hosts=resolved_settings.hosts)
application.add_middleware(
CORSMiddleware,
allow_origins=resolved_settings.origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-CSRF-Token", "X-Request-Id"],
)
application.include_router(auth_router)
application.include_router(accounts_router)
application.include_router(integrations_router)
application.include_router(fleet_router)
application.include_router(rotation_router)
application.include_router(dashboard_router)
@application.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
logger.warning(
"operational_error",
extra={"request_id": request_id, "code": exc.code, "status_code": exc.status_code},
)
content = {
"type": f"about:blank#{exc.code.lower()}",
"title": exc.title,
"status": exc.status_code,
"detail": exc.detail,
"request_id": request_id,
}
if exc.errors:
content["errors"] = [
{"field": item.field, "message": item.message, "code": item.code}
for item in exc.errors
]
return JSONResponse(status_code=exc.status_code, content=content)
@application.exception_handler(RequestValidationError)
async def validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
errors = [
{
"field": ".".join(str(part) for part in item["loc"] if part != "body"),
"message": item["msg"],
"code": item["type"],
}
for item in exc.errors()
]
return JSONResponse(
status_code=422,
content={
"type": "about:blank#validation-error",
"title": "输入校验失败",
"status": 422,
"detail": "请检查表单内容",
"request_id": request_id,
"errors": errors,
},
)
@application.exception_handler(Exception)
async def unexpected_error_handler(request: Request, exc: Exception) -> JSONResponse:
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
logger.exception("unhandled_error", extra={"request_id": request_id})
return JSONResponse(
status_code=500,
content={
"type": "about:blank#internal-error",
"title": "服务器异常",
"status": 500,
"detail": "请稍后重试",
"request_id": request_id,
},
)
@application.get("/healthz", include_in_schema=False)
async def health() -> dict[str, str]:
return {"status": "ok", "version": __version__}
@application.get("/readyz", include_in_schema=False)
async def ready(request: Request) -> JSONResponse:
healthy = request.app.state.container.database.health_check()
return JSONResponse(
status_code=200 if healthy else 503,
content={"status": "ok" if healthy else "degraded", "database": healthy},
)
if STATIC_DIR.exists():
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@application.get("/", include_in_schema=False)
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
return application
app = create_app()