feat(theming): brand-asset overrides and a cached, settings-aware HTML shell (phase 5) #123
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/theming-nav-phase-5"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Phase 5 of
docs/website/THEMING_AND_NAV.md: uploaded logo / hero / favicon overrides on top of theBRAND_*env defaults. Targetsedge; 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:207readindex.htmlat module load, templated it fromBRAND_*, and served that one string forever. Once the favicon and OG image can come from a settings row, that is a lifecycle change, not anawait(§4.3).utils/htmlShell.jsnow owns it, and three properties are the point:invalidate()after a successfulbrand_assets/theme_visualwrite. 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, thenPUTthe row. Two problems: that endpoint isstaffOnly, 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/uploadsthat 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
PUTof 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_assetsneeded its own validatorThese are the only settings values written straight into HTML as URLs the browser then fetches — an
<img src>, a<link rel="icon">, anog:image.utils/brandAssets.jsaccepts 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.SiteContextremoves that block once the/public/settingspayload 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.jsxrenders nothing whenbrand.logois empty — the shipped default — so every surface is unchanged on an untouched instance. It went into all sixMoonDotsurfaces 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_assetsintobrand.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 inhtmlShell.test.jsand 14 inbrandAssets.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:manifestregenerated; manifest diff is exactly the one new route.og:imagepicked it up → an off-originbrand_assets.logowrite 400'd by field → a theme save produced thetheme-bootblock → 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, noog:image, no boot block, zero rows, and/uploadsleft holding exactly the three referenced files.Two things this surfaced
Not fixed, deliberately: the shell's
<title>and meta description still come fromBRAND_NAME/BRAND_DESCRIPTION, not from the admin-setsite_titlethatgetPublic().brand.nameprefers — 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 nobrand_assetsrow, 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.jsfailed 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
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understandevery change, and take responsibility for it. AI-authored commits are
marked with a
Co-Authored-By/Assisted-Bytrailer.License
(GNU GPL v3.0 or later), and I have the right to contribute it.