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:
187
server/src/utils/htmlShell.js
Normal file
187
server/src/utils/htmlShell.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// The SPA's HTML shell: index.html templated with this instance's branding.
|
||||
//
|
||||
// This used to be a one-liner at module load in app.js — read the built
|
||||
// index.html, template it from BRAND_* env, serve that one string forever. The
|
||||
// admin-configurable brand assets (docs/website/THEMING_AND_NAV.md §4.3) make
|
||||
// the favicon and OG image settings-driven, which is a lifecycle change rather
|
||||
// than an `await`: the shell now depends on a row that can change while the
|
||||
// process runs.
|
||||
//
|
||||
// Three properties this module exists to guarantee:
|
||||
//
|
||||
// • It is a cached string in the steady state. A settings read per page view
|
||||
// would put the database on the critical path of every SPA route, including
|
||||
// during an outage where the API is already degraded.
|
||||
// • A DB fault never fails the page. A read error renders the env-only shell —
|
||||
// exactly what the code did before this feature — and that fallback is
|
||||
// cached like any other, so an outage cannot turn every page view into a
|
||||
// failing query.
|
||||
// • With no brand_assets and no theme_visual row it is BYTE-IDENTICAL to what
|
||||
// app.js served before. That is an acceptance criterion of §9, and the
|
||||
// reason the theme <style> block and the asset overrides are appended only
|
||||
// when they exist rather than always emitted with default values.
|
||||
//
|
||||
// Invalidation is explicit — the settings controller calls invalidate() after a
|
||||
// successful write to brand_assets or theme_visual — with a TTL as a safety net.
|
||||
// The cache is per process: in a scaled deployment the process that handled the
|
||||
// write is the only one that learns of it, so without the TTL every other worker
|
||||
// would serve the old favicon until the next restart.
|
||||
|
||||
const brand = require('../config/brand')
|
||||
|
||||
// How long a rendered shell is trusted without an explicit invalidation. Short
|
||||
// enough that a second process converges on its own, long enough that this is
|
||||
// still one render per process per five minutes rather than one per request.
|
||||
const TTL_MS = 5 * 60 * 1000
|
||||
|
||||
// A stored theme reaches the browser twice: in this block, and again as inline
|
||||
// properties once the SPA has fetched /public/settings. The block exists purely
|
||||
// so a themed instance does not paint the shipped palette for one frame first;
|
||||
// the client drops it (by id) as soon as it has the authoritative payload — see
|
||||
// contexts/SiteContext.jsx.
|
||||
const THEME_STYLE_ID = 'theme-boot'
|
||||
|
||||
// Belt and braces over the theme validators. Every token name comes from a fixed
|
||||
// map and every value from a closed set (hex color, curated font stack, bounded
|
||||
// px, listed shadow), so nothing that reaches here can carry markup today. These
|
||||
// two patterns make that a property of the HTML writer rather than of a validator
|
||||
// three modules away that someone may one day loosen.
|
||||
const SAFE_TOKEN_NAME = /^--[a-zA-Z0-9-_]+$/
|
||||
const SAFE_TOKEN_VALUE = /^[a-zA-Z0-9 ,.()#%_'"/-]+$/
|
||||
|
||||
let template = null // the built index.html, read once
|
||||
let cached = null // { html, at }
|
||||
let inflight = null // de-dupes a burst of requests on a cold cache
|
||||
let generation = 0 // bumped by invalidate(); an in-flight render checks it
|
||||
|
||||
// Escape user/brand text for safe interpolation into the HTML shell.
|
||||
function htmlEscape(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded asset path is always relative (`/uploads/…`), but og:image is read
|
||||
* off-site by scrapers that handle a relative URL poorly. Absolutize it against
|
||||
* BRAND_URL when we have one.
|
||||
*
|
||||
* Env values pass through untouched even when relative: the shell an instance
|
||||
* gets today is the operator's choice and must not change just because this
|
||||
* module now exists.
|
||||
*/
|
||||
function absolutize(url) {
|
||||
if (!brand.url || !url.startsWith('/')) return url
|
||||
return `${brand.url.replace(/\/+$/, '')}${url}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the shell. Pure — every input is a parameter, so a test can assert the
|
||||
* byte-identical property without a database.
|
||||
*
|
||||
* @param {string} html the built index.html
|
||||
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
|
||||
* effective brand assets and theme; anything absent falls back to BRAND_* env
|
||||
* @returns {string}
|
||||
*/
|
||||
function render(html, overrides = {}) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
// Effective values: an uploaded override wins over env, absence means env.
|
||||
const logo = overrides.logo ? absolutize(overrides.logo) : brand.logo
|
||||
const favicon = overrides.favicon || brand.favicon
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
logo ? `<meta property="og:image" content="${htmlEscape(logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
|
||||
themeStyleTag(overrides.theme),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// The admin theme as a :root block, or '' when this instance has never been
|
||||
// themed. Injected last in <head> so it follows the built stylesheet and wins
|
||||
// the equal-specificity tie against theme.css's own :root.
|
||||
function themeStyleTag(theme) {
|
||||
if (!theme || typeof theme !== 'object') return ''
|
||||
const decls = Object.entries(theme)
|
||||
.filter(([name, value]) => SAFE_TOKEN_NAME.test(name) && typeof value === 'string' && SAFE_TOKEN_VALUE.test(value))
|
||||
.map(([name, value]) => `${name}:${value}`)
|
||||
.join(';')
|
||||
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the built index.html. Called once at boot by app.js; a separate step
|
||||
* from get() so the file read stays synchronous and startup still fails loudly
|
||||
* if the client build is unreadable.
|
||||
*/
|
||||
function init(html) {
|
||||
template = html
|
||||
cached = null
|
||||
inflight = null
|
||||
generation += 1
|
||||
}
|
||||
|
||||
/** Drop the cached shell. Called after any write that can change it. */
|
||||
function invalidate() {
|
||||
cached = null
|
||||
inflight = null
|
||||
generation += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The current shell. Renders on a cold or expired cache, otherwise returns the
|
||||
* cached string. Never rejects: a settings read that fails yields the env-only
|
||||
* shell.
|
||||
*
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function get() {
|
||||
if (template === null) throw new Error('htmlShell.init() was never called')
|
||||
if (cached && Date.now() - cached.at < TTL_MS) return cached.html
|
||||
if (inflight) return inflight
|
||||
|
||||
const startedAt = generation
|
||||
const run = (async () => {
|
||||
let overrides = {}
|
||||
try {
|
||||
// Required lazily: this module is loaded by app.js at boot, and the
|
||||
// settings model pulls in the DB pool. Requiring it at the top would make
|
||||
// the HTML shell a startup-time dependency of the database.
|
||||
// eslint-disable-next-line global-require
|
||||
const settings = require('../model/settings/settings.model')
|
||||
overrides = await settings.getShellBrand()
|
||||
} catch {
|
||||
// A DB fault must never fail the page (§4.3). Fall back to the env-only
|
||||
// shell — the pre-feature behaviour — and cache it, so an outage does not
|
||||
// mean a failing query per page view.
|
||||
overrides = {}
|
||||
}
|
||||
const html = render(template, overrides)
|
||||
// An invalidation that landed while this read was in flight means the value
|
||||
// we just read may already be stale. Serve it, but do not cache it.
|
||||
if (generation === startedAt) cached = { html, at: Date.now() }
|
||||
// Only retire our own registration: an invalidation during the read may have
|
||||
// already started a newer render, and clearing that one would cost an extra
|
||||
// render on the next request.
|
||||
if (inflight === run) inflight = null
|
||||
return html
|
||||
})()
|
||||
inflight = run
|
||||
return run
|
||||
}
|
||||
|
||||
module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID }
|
||||
Reference in New Issue
Block a user