Matthias Nott
2026-02-22 7d94ec0d18b46893e23680cf8438109a34cc2a10
app/main.py
....@@ -1,12 +1,16 @@
1
+import hashlib
12 import logging
23 from contextlib import asynccontextmanager
34 from pathlib import Path
45
5
-from fastapi import FastAPI
6
+from fastapi import FastAPI, Request
67 from fastapi.middleware.cors import CORSMiddleware
8
+from fastapi.responses import HTMLResponse
79 from fastapi.staticfiles import StaticFiles
10
+from starlette.datastructures import MutableHeaders
11
+from starlette.types import ASGIApp, Receive, Scope, Send
812
9
-from app.routers import backups, restore, services, status, system
13
+from app.routers import backups, promote, rebuild, registry, restore, services, status, sync_data, system
1014
1115 logging.basicConfig(
1216 level=logging.INFO,
....@@ -15,6 +19,13 @@
1519 logger = logging.getLogger(__name__)
1620
1721 _STATIC_DIR = Path(__file__).parent.parent / "static"
22
+
23
+
24
+def _content_hash(filepath: Path) -> str:
25
+ """Return first 12 chars of SHA-256 hex digest of a file's contents."""
26
+ if not filepath.exists():
27
+ return "0"
28
+ return hashlib.sha256(filepath.read_bytes()).hexdigest()[:12]
1829
1930
2031 @asynccontextmanager
....@@ -49,13 +60,72 @@
4960 app.include_router(restore.router, prefix="/api/restore", tags=["restore"])
5061 app.include_router(services.router, prefix="/api/services", tags=["services"])
5162 app.include_router(system.router, prefix="/api/system", tags=["system"])
63
+app.include_router(promote.router, prefix="/api/promote", tags=["promote"])
64
+app.include_router(sync_data.router, prefix="/api/sync", tags=["sync"])
65
+app.include_router(registry.router, prefix="/api/registry", tags=["registry"])
66
+app.include_router(rebuild.router, prefix="/api/rebuild", tags=["rebuild"])
5267
5368 # ---------------------------------------------------------------------------
54
-# Static files – serve CSS/JS at /static and HTML at /
55
-# Mount /static first for explicit asset paths, then / for SPA fallback
69
+# Index route — serves index.html with content-hashed asset URLs.
70
+# The hash changes automatically when JS/CSS files change, so browsers
71
+# always fetch fresh assets after a deploy. No manual version bumping.
72
+# ---------------------------------------------------------------------------
73
+_INDEX_HTML = _STATIC_DIR / "index.html"
74
+
75
+
76
+@app.get("/", response_class=HTMLResponse)
77
+async def serve_index(request: Request):
78
+ """Serve index.html with auto-computed content hashes for cache busting."""
79
+ js_hash = _content_hash(_STATIC_DIR / "js" / "app.js")
80
+ css_hash = _content_hash(_STATIC_DIR / "css" / "style.css")
81
+ html = _INDEX_HTML.read_text()
82
+ # Replace any existing ?v=XX or ?h=XX cache busters with content hash
83
+ import re
84
+ html = re.sub(r'/static/js/app\.js\?[^"]*', f'/static/js/app.js?h={js_hash}', html)
85
+ html = re.sub(r'/static/css/style\.css\?[^"]*', f'/static/css/style.css?h={css_hash}', html)
86
+ return HTMLResponse(content=html, headers={"Cache-Control": "no-cache, must-revalidate"})
87
+
88
+
89
+# ---------------------------------------------------------------------------
90
+# Static files – JS/CSS at /static (cached aggressively — hash busts cache)
5691 # ---------------------------------------------------------------------------
5792 if _STATIC_DIR.exists():
5893 app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static-assets")
59
- app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
6094 else:
6195 logger.warning("Static directory not found at %s – frontend will not be served", _STATIC_DIR)
96
+
97
+
98
+# ---------------------------------------------------------------------------
99
+# Cache-Control ASGI wrapper
100
+# - JS/CSS with ?h= hash: cached forever (immutable, 1 year)
101
+# - Everything else: standard headers
102
+# ---------------------------------------------------------------------------
103
+class CacheControlWrapper:
104
+ def __init__(self, inner: ASGIApp):
105
+ self.inner = inner
106
+
107
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
108
+ if scope["type"] != "http":
109
+ await self.inner(scope, receive, send)
110
+ return
111
+
112
+ path = scope.get("path", "")
113
+ qs = scope.get("query_string", b"").decode()
114
+ has_content_hash = "h=" in qs
115
+
116
+ async def send_with_cache_headers(message):
117
+ if message["type"] == "http.response.start":
118
+ headers = MutableHeaders(scope=message)
119
+ ct = headers.get("content-type", "")
120
+ if has_content_hash and any(t in ct for t in ("javascript", "text/css")):
121
+ # Content-hashed assets: cache forever, hash changes on any edit
122
+ headers["cache-control"] = "public, max-age=31536000, immutable"
123
+ elif any(t in ct for t in ("text/html", "javascript", "text/css")):
124
+ headers["cache-control"] = "no-cache, must-revalidate"
125
+ await send(message)
126
+
127
+ await self.inner(scope, receive, send_with_cache_headers)
128
+
129
+
130
+# Wrap the finished FastAPI app — Uvicorn resolves app.main:app to this
131
+app = CacheControlWrapper(app)