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()