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/`.
This commit is contained in:
+75
-2
@@ -28,6 +28,36 @@ outbound validators. The asymmetry is deliberate: a 304 on
|
||||
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
|
||||
@@ -61,6 +91,9 @@ 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")
|
||||
|
||||
@@ -143,6 +176,7 @@ HTML_PAGES: tuple[str, ...] = (
|
||||
"/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=
|
||||
@@ -317,8 +351,35 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
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:
|
||||
new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8")
|
||||
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.
|
||||
@@ -329,10 +390,22 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
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=_no_cache_headers(response),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user