feat(theming): brand-asset overrides and a cached, settings-aware HTML shell (phase 5) #123

Merged
whitlocktech merged 1 commits from feat/theming-nav-phase-5 into edge 2026-08-08 02:12:48 +00:00
Member

What & why

Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo / hero / favicon overrides on top of the BRAND_* env defaults. Targets edge; docs pair: RunicGateway/docs#106.

An instance that never opens the new screen serves a byte-identical HTML shell and renders identically — verified by a test that keeps a verbatim copy of the old renderer as its reference, and live.

The shell stopped being a boot-time string

app.js:207 read index.html at module load, templated it from BRAND_*, and served that one string forever. Once the favicon and OG image can come from a settings row, that is a lifecycle change, not an await (§4.3). utils/htmlShell.js now owns it, and three properties are the point:

  • One cached string in the steady state. Rendered lazily on first request; concurrent first requests share a single render. A settings read per page view would put the DB on the critical path of every SPA route — including during the outage where the API is already degraded.
  • A DB fault never fails the page. A failed read renders the env-only shell (the pre-feature behaviour) and caches that, so an outage is not a failing query per page view.
  • Invalidation is explicit, with a TTL as a safety net. The settings controller calls invalidate() after a successful brand_assets/theme_visual write. The cache is per process, though: in a scaled deployment the worker that handled the write is the only one that learns of it, so a 5-minute TTL lets the rest converge without going back to a read per view. An invalidation that lands mid-render is not overwritten by the in-flight result.

The upload is one call, not two

§8 says "upload endpoint on the existing multer config", which reads as reuse POST /admin/uploads, then PUT the row. Two problems: that endpoint is staffOnly, and the site's identity is not the editor tier's to change; and a run that uploaded and then failed or was abandoned leaves a file in /uploads that nothing references.

POST /api/v1/admin/settings/brand-asset/:slotadminOnly, shared multer config, returns { url, brand_assets }, read-modify-writes the row so uploading a logo never clears a hero. The per-slot rules only ever tighten the shared allowlist, never widen it (§9): favicon PNG only (§4.10) ≤ 512 KB, logo ≤ 1 MB, hero ≤ the shared 8 MB. The cap is checked after multer writes and the file is unlinked before the response — one upload config and one allowlist is the property worth keeping.

There is no per-slot delete route: clearing one asset is a PUT of the rest, and clearing the last one is the existing reset-by-delete. {} is never stored, because absence of the row is what selects the env defaults.

brand_assets needed its own validator

These are the only settings values written straight into HTML as URLs the browser then fetches — an <img src>, a <link rel="icon">, an og:image. utils/brandAssets.js accepts a same-origin path under /uploads/, /brand/ or /assets/ and nothing else: no scheme, no protocol-relative //host (which looks like a path and loads off-origin), no .., no whitespace or quotes. Same asymmetry as the theme — strict on write with the field named, forgiving on read so one hand-edited slot does not cost the admin the other two. getPublic() now resolves through it too.

Theme flash, deferred by phases 3–4, fixed here

The shell carries the resolved tokens as <style id="theme-boot">:root{…}</style>, injected last in <head> so it follows the built stylesheet and wins the equal-specificity tie. SiteContext removes that block once the /public/settings payload has been applied — otherwise a later reset would clear the inline properties only to reveal the stale block underneath. Removal is gated on a successful fetch, not merely a finished one: a failed request leaves the app with no theme, and dropping the block then would strip a themed instance back to the shipped palette for no reason. Token names and values are re-checked against conservative patterns on the way into the block, so "no markup reaches the HTML" is a property of the writer rather than of a validator three modules away.

The logo, and what it does not touch

components/BrandLogo.jsx renders nothing when brand.logo is empty — the shipped default — so every surface is unchanged on an untouched instance. It went into all six MoonDot surfaces rather than the three §8 named: the admin login, the player login card and the maintenance page carry the same mark, and an operator who uploads a logo means their instance, not three of its pages. On the centered layouts it is stacked above the moon, because turning that block into a flex row would have changed its height on instances with no logo.

The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11) — it is the project's badge, not the instance's.

The hero chain needed no code: §4.9's order already holds because phase 3 resolved brand_assets into brand.hero. What was missing was saying so, and the hero row now states that a hero-editor background wins over the uploaded one.

How it was tested

  • cd server && npm test760 pass, 0 fail (732 before), including 14 new in htmlShell.test.js and 14 in brandAssets.test.js. Load-bearing negatives: https://, //host, javascript:, ../, whitespace and quotes all rejected as asset paths; a non-PNG favicon, an over-cap favicon and an unknown slot each 400 and leave no file on disk; an editor is refused before multer writes; a token carrying </style><script> is dropped rather than escaped; the DB-fault fallback is cached; an invalidation mid-render is not overwritten.
  • cd client && node --test — 75 pass, 0 fail. npm run build — clean.
  • npm run swagger + npm run routes:manifest regenerated; manifest diff is exactly the one new route.
  • Live smoke against the local MariaDB and a temporary admin (since deleted): baseline shell → PNG favicon uploaded → shell served the new one on the next request, no restart → GIF favicon 400 → 600 KB favicon 400, same file accepted as a hero → logo upload merged without dropping the favicon → og:image picked it up → an off-origin brand_assets.logo write 400'd by field → a theme save produced the theme-boot block → in the browser, the block was gone after load with the inline properties agreeing token-for-token, and the logo rendered at 22px beside the moon in the site header → both rows reset → shell back to /assets/img/favicon.ico, no og:image, no boot block, zero rows, and /uploads left holding exactly the three referenced files.

Two things this surfaced

Not fixed, deliberately: the shell's <title> and meta description still come from BRAND_NAME/BRAND_DESCRIPTION, not from the admin-set site_title that getPublic().brand.name prefers — so an instance renamed through the admin panel still shows the env name in its browser tab and link previews. It is a real inconsistency, but fixing it changes the served shell for instances with no brand_assets row, which is precisely what §9's byte-identical criterion forbids in this phase. It wants its own change.

One flake seen once: test/mobileSession.test.js failed in a full-suite run under heavy load and passed on rerun, both alone and in the suite. Timing-sensitive (it asserts on token TTLs), untouched by this PR, and I did not chase it further.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why Phase 5 of [`docs/website/THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/THEMING_AND_NAV.md): uploaded logo / hero / favicon overrides on top of the `BRAND_*` env defaults. Targets **`edge`**; docs pair: RunicGateway/docs#106. An instance that never opens the new screen serves a **byte-identical** HTML shell and renders identically — verified by a test that keeps a verbatim copy of the old renderer as its reference, and live. ### The shell stopped being a boot-time string `app.js:207` read `index.html` at module load, templated it from `BRAND_*`, and served that one string forever. Once the favicon and OG image can come from a settings row, that is a lifecycle change, not an `await` (§4.3). `utils/htmlShell.js` now owns it, and three properties are the point: - **One cached string in the steady state.** Rendered lazily on first request; concurrent first requests share a single render. A settings read per page view would put the DB on the critical path of every SPA route — including during the outage where the API is already degraded. - **A DB fault never fails the page.** A failed read renders the env-only shell (the pre-feature behaviour) and caches *that*, so an outage is not a failing query per page view. - **Invalidation is explicit, with a TTL as a safety net.** The settings controller calls `invalidate()` after a successful `brand_assets`/`theme_visual` write. The cache is **per process**, though: in a scaled deployment the worker that handled the write is the only one that learns of it, so a 5-minute TTL lets the rest converge without going back to a read per view. An invalidation that lands mid-render is not overwritten by the in-flight result. ### The upload is one call, not two §8 says "upload endpoint on the existing multer config", which reads as *reuse `POST /admin/uploads`, then `PUT` the row*. Two problems: that endpoint is `staffOnly`, and the site's identity is not the editor tier's to change; and a run that uploaded and then failed or was abandoned leaves a file in `/uploads` that nothing references. **`POST /api/v1/admin/settings/brand-asset/:slot`** — `adminOnly`, shared multer config, returns `{ url, brand_assets }`, read-modify-writes the row so uploading a logo never clears a hero. The per-slot rules only ever *tighten* the shared allowlist, never widen it (§9): favicon **PNG only** (§4.10) ≤ 512 KB, logo ≤ 1 MB, hero ≤ the shared 8 MB. The cap is checked after multer writes and the file is unlinked before the response — one upload config and one allowlist is the property worth keeping. There is no per-slot delete route: clearing one asset is a `PUT` of the rest, and clearing the last one is the existing reset-by-delete. `{}` is never stored, because absence of the row is what selects the env defaults. ### `brand_assets` needed its own validator These are the only settings values written straight into HTML as URLs the browser then fetches — an `<img src>`, a `<link rel="icon">`, an `og:image`. `utils/brandAssets.js` accepts a same-origin path under `/uploads/`, `/brand/` or `/assets/` and nothing else: no scheme, no protocol-relative `//host` (which looks like a path and loads off-origin), no `..`, no whitespace or quotes. Same asymmetry as the theme — strict on write with the field named, forgiving on read so one hand-edited slot does not cost the admin the other two. `getPublic()` now resolves through it too. ### Theme flash, deferred by phases 3–4, fixed here The shell carries the resolved tokens as `<style id="theme-boot">:root{…}</style>`, injected last in `<head>` so it follows the built stylesheet and wins the equal-specificity tie. `SiteContext` **removes** that block once the `/public/settings` payload has been applied — otherwise a later reset would clear the inline properties only to reveal the stale block underneath. Removal is gated on a **successful** fetch, not merely a finished one: a failed request leaves the app with no theme, and dropping the block then would strip a themed instance back to the shipped palette for no reason. Token names and values are re-checked against conservative patterns on the way into the block, so "no markup reaches the HTML" is a property of the writer rather than of a validator three modules away. ### The logo, and what it does not touch `components/BrandLogo.jsx` renders **nothing** when `brand.logo` is empty — the shipped default — so every surface is unchanged on an untouched instance. It went into all six `MoonDot` surfaces rather than the three §8 named: the admin login, the player login card and the maintenance page carry the same mark, and an operator who uploads a logo means their instance, not three of its pages. On the centered layouts it is stacked *above* the moon, because turning that block into a flex row would have changed its height on instances with no logo. The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11) — it is the project's badge, not the instance's. The hero chain needed no code: §4.9's order already holds because phase 3 resolved `brand_assets` into `brand.hero`. What was missing was *saying* so, and the hero row now states that a hero-editor background wins over the uploaded one. ## How it was tested - `cd server && npm test` — **760 pass, 0 fail** (732 before), including 14 new in `htmlShell.test.js` and 14 in `brandAssets.test.js`. Load-bearing negatives: `https://`, `//host`, `javascript:`, `../`, whitespace and quotes all rejected as asset paths; a non-PNG favicon, an over-cap favicon and an unknown slot each 400 **and leave no file on disk**; an editor is refused before multer writes; a token carrying `</style><script>` is dropped rather than escaped; the DB-fault fallback is cached; an invalidation mid-render is not overwritten. - `cd client && node --test` — 75 pass, 0 fail. `npm run build` — clean. - `npm run swagger` + `npm run routes:manifest` regenerated; manifest diff is exactly the one new route. - **Live smoke** against the local MariaDB and a temporary admin (since deleted): baseline shell → PNG favicon uploaded → shell served the new one **on the next request**, no restart → GIF favicon 400 → 600 KB favicon 400, same file accepted as a hero → logo upload merged without dropping the favicon → `og:image` picked it up → an off-origin `brand_assets.logo` write 400'd by field → a theme save produced the `theme-boot` block → in the browser, the block was **gone** after load with the inline properties agreeing token-for-token, and the logo rendered at 22px beside the moon in the site header → both rows reset → shell back to `/assets/img/favicon.ico`, no `og:image`, no boot block, **zero rows**, and `/uploads` left holding exactly the three referenced files. ## Two things this surfaced **Not fixed, deliberately:** the shell's `<title>` and meta description still come from `BRAND_NAME`/`BRAND_DESCRIPTION`, not from the admin-set `site_title` that `getPublic().brand.name` prefers — so an instance renamed through the admin panel still shows the env name in its browser tab and link previews. It is a real inconsistency, but fixing it changes the served shell for instances with **no** `brand_assets` row, which is precisely what §9's byte-identical criterion forbids in this phase. It wants its own change. **One flake seen once:** `test/mobileSession.test.js` failed in a full-suite run under heavy load and passed on rerun, both alone and in the suite. Timing-sensitive (it asserts on token TTLs), untouched by this PR, and I did not chase it further. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-08-08 01:12:13 +00:00
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon
overrides on top of the BRAND_* env defaults, delivered through an HTML
shell that is no longer built once at boot.

- utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per
  process, invalidated on a brand_assets/theme_visual write with a 5-minute
  TTL so other workers converge. A settings-read failure renders the
  env-only shell and caches that, so a DB outage is not a failing query per
  page view, and with no rows the output is byte-identical to what app.js
  served before.
- POST /admin/settings/brand-asset/:slot uploads one asset and writes the
  row in the same call, so an upload never leaves an unreferenced file. It
  reuses the shared multer allowlist and only tightens it per slot: favicons
  are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused
  files are unlinked before the response.
- utils/brandAssets.js constrains a stored asset to a same-origin path under
  /uploads, /brand or /assets — these are the only settings values written
  straight into the page as a URL. Strict on write, forgiving on read.
- The shell also carries the resolved theme as a <style id="theme-boot">
  block, removing the first-paint flash phases 3-4 deferred; SiteContext
  drops that block once a successful settings fetch has been applied.
- BrandLogo renders beside the MoonDot on all six shells and renders nothing
  when no logo is set, which is the shipped default.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-08 02:12:40 +00:00
whitlocktech merged commit 42a403ad2e into edge 2026-08-08 02:12:48 +00:00
whitlocktech deleted branch feat/theming-nav-phase-5 2026-08-08 02:12:49 +00:00
Sign in to join this conversation.
No description provided.