"""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