| .. | .. |
|---|
| 1 | +import hashlib |
|---|
| 1 | 2 | import logging |
|---|
| 2 | 3 | from contextlib import asynccontextmanager |
|---|
| 3 | 4 | from pathlib import Path |
|---|
| 4 | 5 | |
|---|
| 5 | | -from fastapi import FastAPI |
|---|
| 6 | +from fastapi import FastAPI, Request |
|---|
| 6 | 7 | from fastapi.middleware.cors import CORSMiddleware |
|---|
| 8 | +from fastapi.responses import HTMLResponse |
|---|
| 7 | 9 | from fastapi.staticfiles import StaticFiles |
|---|
| 10 | +from starlette.datastructures import MutableHeaders |
|---|
| 11 | +from starlette.types import ASGIApp, Receive, Scope, Send |
|---|
| 8 | 12 | |
|---|
| 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 |
|---|
| 10 | 14 | |
|---|
| 11 | 15 | logging.basicConfig( |
|---|
| 12 | 16 | level=logging.INFO, |
|---|
| .. | .. |
|---|
| 15 | 19 | logger = logging.getLogger(__name__) |
|---|
| 16 | 20 | |
|---|
| 17 | 21 | _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] |
|---|
| 18 | 29 | |
|---|
| 19 | 30 | |
|---|
| 20 | 31 | @asynccontextmanager |
|---|
| .. | .. |
|---|
| 49 | 60 | app.include_router(restore.router, prefix="/api/restore", tags=["restore"]) |
|---|
| 50 | 61 | app.include_router(services.router, prefix="/api/services", tags=["services"]) |
|---|
| 51 | 62 | 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"]) |
|---|
| 52 | 67 | |
|---|
| 53 | 68 | # --------------------------------------------------------------------------- |
|---|
| 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) |
|---|
| 56 | 91 | # --------------------------------------------------------------------------- |
|---|
| 57 | 92 | if _STATIC_DIR.exists(): |
|---|
| 58 | 93 | app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static-assets") |
|---|
| 59 | | - app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static") |
|---|
| 60 | 94 | else: |
|---|
| 61 | 95 | 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) |
|---|