Files
ducoterra d22d260b8b
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
2026-09-09 17:22:24 -04:00

421 lines
19 KiB
Python

"""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=<token>``).
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
— applies the caching behavior at the transport layer: the known
HTML pages (``HTML_PAGES``) and the dynamic share page
``/shared/<token>`` (phase 51) are always revalidated with a full
200 body (``no-cache``, never a 304) publishing no validators, and
their local asset references are rewritten to carry
``?v=<token>``; ``/assets/*`` is
served ``immutable`` for a year; everything else — all of ``/api/*``,
including the SSE chat stream — passes through byte-identical.
Why pages must never 304 (phase 54): the static file's validators
(``etag`` / ``last-modified``) describe the *file*, but the bytes the
browser receives are the *rewritten body* this process built from its
own token. A conditional request that matched those validators would
304 out of the rewrite — the browser keeps the HTML it already has,
whose ``?v=`` points at the previous commit's CSS/JS, which are cached
``immutable`` for a year. So on page paths the middleware strips
``if-none-match`` / ``if-modified-since`` from the inbound request
(before the downstream ``FileResponse`` can act on them) and drops the
outbound validators. The asymmetry is deliberate: a 304 on
``/assets/*`` is safe because the URL itself encodes the version, while
a 304 on an HTML page is never safe — the served body depends on the
process token, which the validator ignores.
Phase 91 (task 02) — the pre-paint theme tag: in the SAME rewrite
branch, AFTER the ``?v=<token>`` asset rewrite, the effective
``ui_settings`` row (task 01's :func:`app.core.theming.effective_settings`
resolver — one short-lived session per response, NO process cache: the
owner changes the theme at runtime from the admin tab, so the next
request must see it without a restart, and a single-row SELECT is
negligible at homelab page traffic) is rendered as an inline
``<style id="bor-theme">:root{…}</style>`` and inserted immediately
BEFORE the first ``</head>`` (:func:`app.core.theming.inject_theme`),
so a themed deployment paints its palette on the FIRST paint — no red
flash, no pop-in. An unset/defaults deployment gets ``tag == ""`` —
the identity no-op — and serves the EXACT pre-phase-91 rewrite-only
bytes (the byte-identical contract, B4); a DB blip (or a pre-migration
boot) is the same no-op, the page never breaks. The asset rewrite is
untouched, and ``/api/*`` / ``/assets/*`` still pass through
byte-identical.
Phase 91 (task 05, defect fix) — the CSP extension: the phase-82
policy (A1, ``default-src 'self'`` with no ``style-src``) BLOCKS the
inline tag in every real browser, so a themed HTML page's response
also carries ``style-src 'self' 'sha256-<hash>'`` appended to the A1
string, where ``<hash>`` is the CSP3 hash of the EXACT tag content
(:func:`app.core.theming.theme_csp_hash`) — the current theme is the
only inline style ever permitted (no ``'unsafe-inline'``; a different
palette or any other inline style is still blocked). The untagged
response keeps the plain A1 string (the outer
:class:`~app.core.security_headers.SecurityHeadersMiddleware`
preserves a CSP an inner layer has already set), and no non-HTML
response ever gets the extension.
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.datastructures import MutableHeaders
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from app.config import get_settings
from app.core import theming
from app.core.security_headers import CSP
from app.db import SessionLocal
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 <repo_root> 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
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
# phase 51: the shared page's STATIC path (the static mount serves
# shared.html at /shared.html as well as the real route serves the
# dynamic /shared/<token> — both must carry the no-cache + ?v=
# contract, so the direct URL can never pin stale assets).
"/shared.html",
# phase 59: the doc edit screen (the flow page task 06 ships).
"/doc-edit.html",
)
#: Prefix of the versioned static assets (header-only caching; the body is
#: never read or modified).
ASSETS_PREFIX = "/assets/"
#: ``/assets/*`` — the URL carries ``?v=<token>``, 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 _is_known_page(path: str) -> bool:
"""One source of truth for the known-page contract (phase 54).
True for ``HTML_PAGES`` and for the dynamic share page
``/shared/<token>`` (phase 51) by path prefix. Shared by the
inbound conditional-header strip and the outbound rewrite branch so
the two can never drift apart.
"""
return path in HTML_PAGES or path.startswith("/shared/")
def rewrite_asset_refs(html: str, token: str) -> str:
"""Append ``?v=<token>`` 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), drop the upstream validators —
``etag`` / ``last-modified`` describe the static file, not the
rewritten body, so a page response must never be revalidated against
them later (phase 54) — and force ``Cache-Control: no-cache``."""
headers = {k: v for k, v in response.headers.items()}
headers.pop("content-length", None)
for name in ("etag", "last-modified"):
headers.pop(name, None)
headers["Cache-Control"] = HTML_CACHE_CONTROL
return headers
class CachingMiddleware(BaseHTTPMiddleware):
"""Transport-layer cache busting (phase 33, revalidation fix phase 54).
Touches exactly two response shapes:
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
(header only — the body is never read; conditional requests may
still 304, which is safe because the URL carries ``?v=<token>``).
* the known HTML pages (``HTML_PAGES``) plus the dynamic share page
``/shared/<token>`` (phase 51, by path prefix) — always revalidated
with a full 200 body (no 304), publishing no validators: the inbound
``if-none-match`` / ``if-modified-since`` are stripped before the
downstream app runs, ``Cache-Control: no-cache`` is set, the outbound
``etag`` / ``last-modified`` are dropped, and (for ``text/html``
bodies) every local asset reference gains ``?v=<token>``.
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:
path = request.url.path
# Phase 54: on known page paths, strip the conditional request
# headers BEFORE the downstream app runs. The 304 is produced
# downstream (Starlette's ``FileResponse``/``StaticFiles`` honours
# ``If-None-Match`` / ``If-Modified-Since`` before this middleware
# sees a response), so a validator match would return a bodiless
# 304 whose body can never be rewritten to carry the current
# ``?v=<token>`` refs — the browser would keep HTML pointing at
# the previous commit's assets, immutable-cached for a year.
# ``MutableHeaders`` mutates ``request.scope["raw_headers"]`` in
# place, so the downstream app only ever sees the full 200. The
# strip is scoped to page paths only — ``/assets/*`` 304s are safe
# (the URL is versioned) and ``/api/*`` (incl. the SSE stream)
# must stay byte-identical.
if _is_known_page(path):
headers = MutableHeaders(scope=request.scope)
for name in ("if-none-match", "if-modified-since"):
if name in headers:
del headers[name]
response = await call_next(request)
if path.startswith(ASSETS_PREFIX):
response.headers["Cache-Control"] = ASSET_CACHE_CONTROL
return response
if not _is_known_page(path):
# /api/* (incl. SSE), /favicon.ico, unknown paths: untouched.
return response
# Phase 54: a bodiless downstream status (204/304) on a page
# path is NEVER rewritten — starlette forbids a body on those
# statuses, and the rewrite path would build exactly that.
# Belt-and-braces: after the inbound conditional-header strip,
# StaticFiles cannot 304 these paths; a future route or proxy
# could still, so pass through with no-cache and no validators.
if response.status_code in (204, 304):
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
for name in ("etag", "last-modified"):
if name in response.headers:
del response.headers[name]
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
# Phase 91 (task 02): the pre-paint theme tag. One short-lived
# session per response (the sync-endpoint house pattern from
# app/db.py — the middleware world is sync); NO process cache —
# the theme changes at runtime from the admin tab, so the next
# request must see it without a restart. A DB blip (or a
# pre-migration boot) must never break the page: fall back to
# ``tag == ""`` (the built-in palette) and keep the no-cache
# contract (loadHealth house style).
tag = ""
try:
db = SessionLocal()
try:
effective = theming.effective_settings(db)
finally:
db.close()
tag = theming.theme_style_tag(
{key: effective[key] for key in theming.COLOR_FIELDS}
)
except Exception:
logger.exception(
"cache busting: theme read failed for %s — serving without the theme tag",
path,
)
tag = ""
try:
new_body = theming.inject_theme(
rewrite_asset_refs(body.decode("utf-8"), token), tag
).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),
)
headers = _no_cache_headers(response)
if tag:
# Phase 91 (task 05): the inline tag needs a style-src
# exemption or the phase-82 CSP blocks it in the browser —
# the strictest one: a sha256 hash of the EXACT tag content
# (theming.theme_csp_hash), appended to the A1 string. The
# outer SecurityHeadersMiddleware preserves this (it only
# fills in a missing CSP); the untagged page keeps A1
# verbatim — byte- AND header-identical to pre-phase-91.
headers["Content-Security-Policy"] = (
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
)
return Response(
content=new_body,
status_code=response.status_code,
headers=headers,
)
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)