import hashlib import logging from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from starlette.datastructures import MutableHeaders from starlette.types import ASGIApp, Receive, Scope, Send from app.routers import backups, promote, rebuild, registry, restore, services, status, sync_data, system logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger(__name__) _STATIC_DIR = Path(__file__).parent.parent / "static" def _content_hash(filepath: Path) -> str: """Return first 12 chars of SHA-256 hex digest of a file's contents.""" if not filepath.exists(): return "0" return hashlib.sha256(filepath.read_bytes()).hexdigest()[:12] @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Ops WebUI server is running") yield app = FastAPI( title="Ops WebUI API", description="Backend API for the ops web dashboard", version="1.0.0", lifespan=lifespan, ) # --------------------------------------------------------------------------- # CORS – open for development; restrict in production via env/reverse proxy # --------------------------------------------------------------------------- app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # API routers # --------------------------------------------------------------------------- app.include_router(status.router, prefix="/api/status", tags=["status"]) app.include_router(backups.router, prefix="/api/backups", tags=["backups"]) app.include_router(restore.router, prefix="/api/restore", tags=["restore"]) app.include_router(services.router, prefix="/api/services", tags=["services"]) app.include_router(system.router, prefix="/api/system", tags=["system"]) app.include_router(promote.router, prefix="/api/promote", tags=["promote"]) app.include_router(sync_data.router, prefix="/api/sync", tags=["sync"]) app.include_router(registry.router, prefix="/api/registry", tags=["registry"]) app.include_router(rebuild.router, prefix="/api/rebuild", tags=["rebuild"]) # --------------------------------------------------------------------------- # Index route — serves index.html with content-hashed asset URLs. # The hash changes automatically when JS/CSS files change, so browsers # always fetch fresh assets after a deploy. No manual version bumping. # --------------------------------------------------------------------------- _INDEX_HTML = _STATIC_DIR / "index.html" @app.get("/", response_class=HTMLResponse) async def serve_index(request: Request): """Serve index.html with auto-computed content hashes for cache busting.""" js_hash = _content_hash(_STATIC_DIR / "js" / "app.js") css_hash = _content_hash(_STATIC_DIR / "css" / "style.css") html = _INDEX_HTML.read_text() # Replace any existing ?v=XX or ?h=XX cache busters with content hash import re html = re.sub(r'/static/js/app\.js\?[^"]*', f'/static/js/app.js?h={js_hash}', html) html = re.sub(r'/static/css/style\.css\?[^"]*', f'/static/css/style.css?h={css_hash}', html) return HTMLResponse(content=html, headers={"Cache-Control": "no-cache, must-revalidate"}) # --------------------------------------------------------------------------- # Static files – JS/CSS at /static (cached aggressively — hash busts cache) # --------------------------------------------------------------------------- if _STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static-assets") else: logger.warning("Static directory not found at %s – frontend will not be served", _STATIC_DIR) # --------------------------------------------------------------------------- # Cache-Control ASGI wrapper # - JS/CSS with ?h= hash: cached forever (immutable, 1 year) # - Everything else: standard headers # --------------------------------------------------------------------------- class CacheControlWrapper: def __init__(self, inner: ASGIApp): self.inner = inner async def __call__(self, scope: Scope, receive: Receive, send: Send): if scope["type"] != "http": await self.inner(scope, receive, send) return path = scope.get("path", "") qs = scope.get("query_string", b"").decode() has_content_hash = "h=" in qs async def send_with_cache_headers(message): if message["type"] == "http.response.start": headers = MutableHeaders(scope=message) ct = headers.get("content-type", "") if has_content_hash and any(t in ct for t in ("javascript", "text/css")): # Content-hashed assets: cache forever, hash changes on any edit headers["cache-control"] = "public, max-age=31536000, immutable" elif any(t in ct for t in ("text/html", "javascript", "text/css")): headers["cache-control"] = "no-cache, must-revalidate" await send(message) await self.inner(scope, receive, send_with_cache_headers) # Wrap the finished FastAPI app — Uvicorn resolves app.main:app to this app = CacheControlWrapper(app)