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

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>
This commit is contained in:
2026-08-07 20:09:56 -05:00
parent 02580ebda3
commit 847cfd2d2b
22 changed files with 1488 additions and 35 deletions

View File

@@ -2,6 +2,7 @@ const settingsDb = require('./settings.db')
const brand = require('../../config/brand')
const { parseJsonSetting } = require('../../utils/settingsJson')
const { resolveThemeTokens } = require('../../utils/themeResolve')
const { resolveBrandAssets } = require('../../utils/brandAssets')
// Keys safe to expose on the public site.
const PUBLIC_KEYS = [
@@ -158,10 +159,11 @@ async function getPublic() {
// site actually paints. See THEMING_AND_NAV.md §6.
const theme = resolveThemeTokens(all.theme_visual)
if (theme) out.theme = theme
// Uploaded brand-asset overrides (§6.3). Written by the Phase 5 admin UI;
// resolved here so every consumer of the brand block — the SPA, the Android
// app, the Discord bot — picks them up through the one contract.
const brandAssets = parseJsonSetting(all.brand_assets) || {}
// Uploaded brand-asset overrides (§6.3), resolved here so every consumer of
// the brand block — the SPA, the Android app, the Discord bot — picks them up
// through the one contract. Forgiving on read like the theme: a slot holding
// something we would not emit as a URL is dropped and its neighbours kept.
const brandAssets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
// Instance branding (BRAND_* env defaults). The admin-editable settings —
// site title, contact email, and now the theme accent and uploaded assets —
// override the env value when set, so existing installs keep their
@@ -199,6 +201,28 @@ async function getPublic() {
return out
}
/**
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
* effective favicon and logo, plus the theme token map for the boot <style>
* block. Kept here rather than in utils/htmlShell.js so there is one authority
* for "which asset wins", and so the shell can never disagree with the payload
* the SPA fetches a moment later.
*
* Throws on a DB fault — the caller (utils/htmlShell.js) decides what a failure
* means for the page, and for it the answer is "serve the env-only shell".
*
* @returns {Promise<{logo: string, favicon: string, theme: object|null}>}
*/
async function getShellBrand() {
const all = await getAll()
const assets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
return {
logo: assets.logo || brand.logo,
favicon: assets.favicon || brand.favicon,
theme: resolveThemeTokens(all.theme_visual),
}
}
// The two nav-override keys their own audiences need but cannot read from
// GET /admin/settings (admin-only, while AdminLayout renders for editors and
// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md
@@ -230,6 +254,7 @@ module.exports = {
setMany,
getAll,
getPublic,
getShellBrand,
getNav,
getInstanceName,
PUBLIC_KEYS,