156 lines
5.5 KiB
Python
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
|