Files
Cloud-Tour-to-Libo/app/main.py
T
2026-08-26 13:13:51 +08:00

156 lines
5.5 KiB
Python

"""City Knowledge Graph Admin — FastAPI entry point."""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.requests import Request
from app.api import api_router, openapi_router
from app.api.mcp_server import router as mcp_router
from app.db import init_pool, close_pool
from app.data_platform.mysql_db import close_data_pool, init_data_pool
from app.data_platform.mysql_service import ensure_platform_registry
from app.config import settings
from app.security_baseline import enforce_security_baseline
def _csv_setting(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if not settings.security_headers_enabled:
return response
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(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=()",
)
if request.url.path.startswith("/v1/"):
response.headers.setdefault("Cache-Control", "no-store")
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip()
if request.url.scheme == "https" or forwarded_proto == "https":
response.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
return response
class SPAStaticFiles(StaticFiles):
"""Serve the admin SPA with client-side-routing fallback.
Any unknown path under /admin (e.g. /admin/login-v2, /admin/plaza/graph)
falls back to index.html so React Router can handle it on a hard
navigation or page refresh — instead of returning a 404.
"""
@staticmethod
def _without_conditional_cache_headers(scope):
"""Force SPA entry points to return the current index.html body.
StaticFiles normally honours If-None-Match/If-Modified-Since. That is
useful for content-hashed assets, but a cached index.html can otherwise
keep pointing at an obsolete JavaScript bundle after a deployment.
"""
fresh_scope = dict(scope)
fresh_scope["headers"] = [
(name, value)
for name, value in scope.get("headers", [])
if name.lower() not in {b"if-none-match", b"if-modified-since"}
]
return fresh_scope
@staticmethod
def _set_cache_policy(response, *, spa_entry: bool):
if spa_entry:
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
else:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def get_response(self, path: str, scope):
spa_entry = path in {"", ".", "index.html"}
request_scope = (
self._without_conditional_cache_headers(scope) if spa_entry else scope
)
try:
response = await super().get_response(path, request_scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
spa_entry = True
response = await super().get_response(
"index.html", self._without_conditional_cache_headers(scope)
)
else:
raise
return self._set_cache_policy(response, spa_entry=spa_entry)
@asynccontextmanager
async def lifespan(_app: FastAPI):
enforce_security_baseline()
await init_pool()
try:
if await init_data_pool():
await ensure_platform_registry()
yield
finally:
try:
await close_data_pool()
finally:
await close_pool()
app = FastAPI(
title="ZN-KG Admin",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=_csv_setting(settings.cors_allowed_origins),
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-API-Key", "X-KG-API-Key"],
expose_headers=["Content-Disposition", "X-Exported-Count"],
)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=_csv_setting(settings.trusted_hosts) or ["localhost", "127.0.0.1"],
)
app.add_middleware(SecurityHeadersMiddleware)
app.include_router(api_router)
app.include_router(openapi_router)
app.include_router(mcp_router)
@app.get("/", include_in_schema=False)
async def root_redirect() -> RedirectResponse:
"""Send the public domain root to the bundled admin application."""
return RedirectResponse(url="/admin/", status_code=307)
# Serve built admin-web SPA (when available)
try:
app.mount("/admin", SPAStaticFiles(directory="app/static/admin", html=True), name="admin-web")
except Exception:
pass