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:
95
server/src/utils/brandAssets.js
Normal file
95
server/src/utils/brandAssets.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Uploaded brand-asset overrides — the `brand_assets` settings row.
|
||||
//
|
||||
// { "logo": "/uploads/1234-ab.png", "hero": null, "favicon": null }
|
||||
//
|
||||
// Each field, once set, holds a stored upload URL; a null or absent field falls
|
||||
// back to brand.logo / brand.hero / brand.favicon from BRAND_* env. Uploading a
|
||||
// logo does not force the admin to also pick a hero
|
||||
// (docs/website/THEMING_AND_NAV.md §6.3).
|
||||
//
|
||||
// These values are the only part of the settings store that is written straight
|
||||
// into HTML the browser then fetches — an <img src>, a <link rel="icon">, an
|
||||
// og:image. So the accepted shape is deliberately narrow: a same-origin path
|
||||
// under one of the three directories this app serves, and nothing else. No
|
||||
// scheme, no protocol-relative `//host`, no `..`. The upload route only ever
|
||||
// produces `/uploads/…`, so the other two prefixes exist for an admin who wants
|
||||
// to point at an asset already baked into the image or mounted at /brand.
|
||||
//
|
||||
// Same strict-on-write / forgiving-on-read asymmetry as the theme
|
||||
// (utils/themeResolve.js): a bad write is rejected with the field named, while a
|
||||
// bad *stored* value is dropped field by field so a hand-edited row degrades to
|
||||
// the env default instead of rendering a broken page.
|
||||
|
||||
// The three overridable assets, in the order the admin UI shows them.
|
||||
const SLOTS = ['logo', 'hero', 'favicon']
|
||||
|
||||
// Directories this server actually serves: /uploads (UPLOAD_DIR), /brand
|
||||
// (BRAND_DIR, optional) and /assets (the built SPA's static files).
|
||||
const ALLOWED_PREFIXES = ['/uploads/', '/brand/', '/assets/']
|
||||
|
||||
/**
|
||||
* Is this a value we are willing to emit as a URL into the page?
|
||||
* @param {unknown} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeAssetPath(value) {
|
||||
if (typeof value !== 'string' || value === '') return false
|
||||
// A leading `//` is protocol-relative and would load from another origin
|
||||
// despite looking like a path; `..` could climb out of the served directory.
|
||||
if (value.startsWith('//') || value.includes('..')) return false
|
||||
// Whitespace and control characters have no place in a stored path and are the
|
||||
// raw material for `javascript:` smuggling past a naive prefix check.
|
||||
if (/[\s<>"'\\]/.test(value)) return false
|
||||
return ALLOWED_PREFIXES.some((prefix) => value.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a brand_assets object for WRITING. Strict: names the offending field.
|
||||
* @param {unknown} value the parsed object (or null to clear every slot)
|
||||
* @returns {{ok: true} | {ok: false, message: string}}
|
||||
*/
|
||||
function validateBrandAssets(value) {
|
||||
if (value === null || value === undefined) return { ok: true }
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ok: false, message: 'brand_assets must be a JSON object' }
|
||||
}
|
||||
for (const [slot, url] of Object.entries(value)) {
|
||||
if (!SLOTS.includes(slot)) {
|
||||
return { ok: false, message: `Unknown brand asset '${slot}'` }
|
||||
}
|
||||
// null/'' is how a slot is cleared back to the env default — allowed, and
|
||||
// stripped by the caller so the stored row never carries dead fields.
|
||||
if (url === null || url === '') continue
|
||||
if (!isSafeAssetPath(url)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `brand_assets.${slot} must be an uploaded path under /uploads/, /brand/ or /assets/`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the slots that hold a usable path. Serves both directions on
|
||||
* purpose:
|
||||
*
|
||||
* • writing — an admin who removes their logo stores `{}` (and the caller
|
||||
* deletes the row entirely) rather than a row full of nulls, which would
|
||||
* read as "set to nothing" rather than "never set";
|
||||
* • reading — an unusable stored field is dropped and its neighbours kept, so
|
||||
* one bad slot cannot cost the admin the other two.
|
||||
*
|
||||
* @param {object|null} value an object, or a parseJsonSetting result
|
||||
* @returns {{logo?: string, hero?: string, favicon?: string}}
|
||||
*/
|
||||
function resolveBrandAssets(value) {
|
||||
const out = {}
|
||||
if (!value || typeof value !== 'object') return out
|
||||
for (const slot of SLOTS) {
|
||||
if (isSafeAssetPath(value[slot])) out[slot] = value[slot]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { SLOTS, ALLOWED_PREFIXES, isSafeAssetPath, validateBrandAssets, resolveBrandAssets }
|
||||
Reference in New Issue
Block a user