221 lines
8.7 KiB
Python
221 lines
8.7 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:
|
|
container = request.app.state.container
|
|
database_healthy = container.database.health_check()
|
|
scheduler = container.scheduler_loop.status(enabled=container.settings.scheduler_enabled)
|
|
healthy = database_healthy and bool(scheduler["healthy"])
|
|
return JSONResponse(
|
|
status_code=200 if healthy else 503,
|
|
content={
|
|
"status": "ok" if healthy else "degraded",
|
|
"database": database_healthy,
|
|
"scheduler": scheduler,
|
|
},
|
|
)
|
|
|
|
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()
|