Matthias Nott
2026-02-22 f80c96be55296d0f6184a9fdff8fbe0409a23a46
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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)