"""Static-frontend cache busting (phase 33). Two layers, one module: * **Version token** (``asset_version()``) — the value the HTML pages append to their asset URLs (``?v=``). * **Response middleware** (``CachingMiddleware`` / ``configure_caching``) — applies the caching behavior at the transport layer: the known HTML pages (``HTML_PAGES``) are always revalidated (``no-cache``) and their local asset references are rewritten to carry ``?v=``; ``/assets/*`` is served ``immutable`` for a year; everything else — all of ``/api/*``, including the SSE chat stream — passes through byte-identical. The token is computed **once per process** (``functools.cache``, i.e. ``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes when a new commit lands (git path) or the frontend tree's mtimes/sizes change (fallback path); either way the change takes effect on the next process start. Tests clear it with ``asset_version.cache_clear()``. Version-token sources: * **Git checkouts** (the homelab reality — the project root next to ``frontend/`` has a ``.git``): the token is ``git rev-parse --short HEAD``. A commit is a deploy, so the token flips on every deploy. * **Plain directories** (dev checkouts without a ``.git``, or a git failure): a stable content hash — first 12 hex chars of SHA-256 over the sorted ``relpath:mtime_ns:size`` list of every regular file under the static dir. Touching or editing a file (plus a process restart) busts the cache. * **Missing/empty directory**: ``"dev"``. """ from __future__ import annotations import functools import hashlib import logging import re import subprocess from pathlib import Path from fastapi import FastAPI from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.requests import Request from starlette.responses import Response from app.config import get_settings logger = logging.getLogger("app") #: Token for a missing/empty static dir (e.g. a build that ships the API #: only — there is nothing on disk to version). DEV_TOKEN = "dev" #: Hard cap for the ``git rev-parse`` call — a deploy must not hang a boot. _GIT_TIMEOUT_S = 5 def _static_files(static_dir: Path) -> list[str]: """Sorted ``relpath:mtime_ns:size`` entries for every regular file.""" entries: list[str] = [] for path in static_dir.rglob("*"): if path.is_file(): st = path.stat() rel = path.relative_to(static_dir).as_posix() entries.append(f"{rel}:{st.st_mtime_ns}:{st.st_size}") return sorted(entries) def _git_short_sha(repo_root: Path) -> str: """``git -C rev-parse --short HEAD`` (raises on any failure).""" proc = subprocess.run( ["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=_GIT_TIMEOUT_S, check=True, ) sha = proc.stdout.strip() if not sha: raise ValueError("git returned an empty short SHA") return sha @functools.cache # == lru_cache(maxsize=None): one token per process def asset_version(static_dir: str | None = None) -> str: """Version token for the frontend's asset URLs (see module docstring). ``static_dir`` defaults to ``get_settings().static_dir`` (``frontend``); the git repo root is taken to be its parent. The result is cached for the process lifetime — clear it with ``asset_version.cache_clear()``. """ target = get_settings().static_dir if static_dir is None else static_dir directory = Path(target).expanduser().resolve() files = _static_files(directory) if directory.is_dir() else [] if not files: return DEV_TOKEN repo_root = directory.parent if (repo_root / ".git").exists(): try: return _git_short_sha(repo_root) except (OSError, subprocess.SubprocessError, ValueError): # git missing (OSError), timeout or non-zero exit # (SubprocessError — e.g. a repo with no commits), or empty # output → the stable content-hash fallback keeps dev checkouts # (and broken repos) cache-busting instead of breaking a boot. pass digest = hashlib.sha256("\n".join(files).encode("utf-8")).hexdigest() return digest[:12] # --------------------------------------------------------------------------- # Response-layer cache busting (task 02) # --------------------------------------------------------------------------- #: The known HTML pages — the ONLY paths whose body is rewritten. #: Phase 35 adds the admin git sources page: without this entry it #: would serve unversioned asset refs, which the immutable-for-a-year #: asset caching would pin to stale CSS after a deploy. HTML_PAGES: tuple[str, ...] = ( "/", "/index.html", "/sources.html", "/document.html", "/login.html", "/tuning.html", "/git-sources.html", # phase 35: the admin git sources page "/history.html", # phase 50: the admin saved-chats page ) #: Prefix of the versioned static assets (header-only caching; the body is #: never read or modified). ASSETS_PREFIX = "/assets/" #: ``/assets/*`` — the URL carries ``?v=``, so long-term caching is #: safe (a deploy changes the token, hence the URL). ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" #: HTML pages are always revalidated — the page is the cheap redirector to #: the (long-cached) versioned assets. HTML_CACHE_CONTROL = "no-cache" #: Matches ``href="…assets/…"`` / ``src="…assets/…"`` references — leading #: slash optional (the pages mix ``/assets/…`` and ``assets/…``) — that do #: NOT already carry a query string or a fragment: ``[^"?#]+`` stops at #: ``?``/``#``, so an already-``?v=``-tagged reference can never match #: again (idempotent rewrite, never a double ``?v=``). _ASSET_REF_RE = re.compile(r'((?:href|src)="(?:/)?assets/[^"?#]+)(")') def rewrite_asset_refs(html: str, token: str) -> str: """Append ``?v=`` before the closing quote of every local ``assets/…`` ``href``/``src`` reference. Pure and idempotent: references that already carry a query string or a fragment (``?v=…``, ``#frag``) do not match the pattern and are left alone, and non-asset references (``href="/sources.html"``, ``href="data:…"``) pass through untouched. """ return _ASSET_REF_RE.sub(lambda m: f"{m.group(1)}?v={token}{m.group(2)}", html) async def _read_body(response: Response) -> bytes: """Buffer the whole response body. starlette 1.x's ``BaseHTTPMiddleware.call_next`` returns a streaming response (no ``body()`` helper), so drain ``body_iterator``; a plain buffered ``Response`` simply carries its ``body``. """ if hasattr(response, "body"): raw = response.body # bytes | memoryview (Response.render's output) if isinstance(raw, memoryview): return bytes(raw) return raw # starlette 1.x's BaseHTTPMiddleware.call_next wraps the app response # in a streaming shell (``body_iterator``, no ``body``) that is NOT a # StreamingResponse subclass — drain the iterator instead. iterator = response.body_iterator # pyright: ignore[reportAttributeAccessIssue] return b"".join([chunk async for chunk in iterator]) def _no_cache_headers(response: Response) -> dict[str, str]: """Copy the original headers, drop the stale ``content-length`` (the body size changes on rewrite), and force ``Cache-Control: no-cache``.""" headers = {k: v for k, v in response.headers.items()} headers.pop("content-length", None) headers["Cache-Control"] = HTML_CACHE_CONTROL return headers class CachingMiddleware(BaseHTTPMiddleware): """Transport-layer cache busting (phase 33). Touches exactly two response shapes: * ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable`` (header only — the body is never read). * the known HTML pages (``HTML_PAGES``) — ``Cache-Control: no-cache``, and (for ``text/html`` bodies) every local asset reference gains ``?v=``. Everything else — all of ``/api/*`` (including the SSE chat stream) — passes through byte-identical: no header changes, the body stream is never drained. """ async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: response = await call_next(request) path = request.url.path if path.startswith(ASSETS_PREFIX): response.headers["Cache-Control"] = ASSET_CACHE_CONTROL return response if path not in HTML_PAGES: # /api/* (incl. SSE), /favicon.ico, unknown paths: untouched. return response content_type = response.headers.get("content-type", "") if content_type.split(";", 1)[0].strip().lower() != "text/html": # A page path with a non-HTML body (e.g. the 404 JSON served # when the static dir is missing): still revalidate, body as-is. response.headers["Cache-Control"] = HTML_CACHE_CONTROL return response # ``asset_version()`` is lru_cached, so only the very first call in # the process can fail — resolve it BEFORE draining the body stream # below. try: token = asset_version() body = await _read_body(response) except Exception: logger.exception( "cache busting: could not buffer %s — serving unmodified (no-cache)", path, ) response.headers["Cache-Control"] = HTML_CACHE_CONTROL return response try: new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8") except Exception: # The body IS buffered — re-serve the ORIGINAL bytes so a # rewrite hiccup never loses the page. logger.exception("cache busting: rewrite of %s failed — serving raw (no-cache)", path) return Response( content=body, status_code=response.status_code, headers=_no_cache_headers(response), ) return Response( content=new_body, status_code=response.status_code, headers=_no_cache_headers(response), ) def configure_caching(app: FastAPI) -> None: """Attach :class:`CachingMiddleware` to ``app`` (phase 33). Call inside ``create_app()`` after the routers are mounted; the middleware wraps the whole app, so the ``/`` static catch-all is covered too. ``/api/*`` responses (including the SSE chat stream) are never touched by it. """ app.add_middleware(CachingMiddleware)