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

@@ -1,3 +1,5 @@
const fs = require('fs')
const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model')
@@ -11,6 +13,8 @@ const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml')
const { parseJsonSetting } = require('../../../utils/settingsJson')
const { validateThemeVisual } = require('../../../utils/themeResolve')
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
const htmlShell = require('../../../utils/htmlShell')
const log = require('../../../utils/logger')('admin')
@@ -547,8 +551,28 @@ async function updateSettings(req, res) {
if (!check.ok) return res.status(400).json({ message: check.message })
updates.theme_visual = JSON.stringify(parsed)
}
// brand_assets holds the only settings values written straight into HTML the
// browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
// the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
// dropped rather than stored as null, keeping "a field is absent" the single
// meaning of "falls back to BRAND_* env".
if ('brand_assets' in updates) {
const raw = updates.brand_assets
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
if (typeof raw === 'string' && parsed === null) {
return res.status(400).json({ message: 'brand_assets must be a JSON object' })
}
const check = validateBrandAssets(parsed)
if (!check.ok) return res.status(400).json({ message: check.message })
updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
}
try {
await settings.setMany(updates, req.user.id)
// The HTML shell is templated from brand_assets and theme_visual, and is
// cached per process (utils/htmlShell.js) — a write that can change it has
// to say so, or the favicon an admin just uploaded appears only after the
// cache's TTL.
if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
return res.json(await settings.getAll())
} catch (err) {
@@ -557,6 +581,87 @@ async function updateSettings(req, res) {
}
}
// ── Brand assets ──────────────────────────────────────────────────────
//
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
// itself is never widened (§9: "no second upload path with weaker validation") —
// these only ever tighten it:
//
// • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
// fact that the stored extension comes from that map is exactly what makes
// the upload path safe (§4.10). Every browser this app supports takes a PNG
// icon. Small cap: a favicon is a handful of KB.
// • logo — a header mark next to the site title, not a page image.
// • hero — a full-bleed background, so it keeps the shared ceiling.
//
// The cap is checked after multer has written the file rather than by a second
// multer instance: one upload config, one allowlist, and the oversized file is
// unlinked before we answer.
const ASSET_RULES = {
logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
}
const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)
// Best-effort cleanup of a file we have decided not to keep. A failure here is
// a stray file in /uploads, not something the caller can act on.
async function discardUpload(file) {
try {
await fs.promises.unlink(file.path)
} catch (err) {
log.error('discardUpload', err)
}
}
/**
* POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
* brand_assets row at it in the same call.
*
* One call rather than "upload, then PUT the settings row": a half-completed
* save would otherwise leave a file in /uploads that nothing references, and the
* per-slot rules above need the slot at upload time anyway. Admin-only, matching
* the gate on the settings it writes — POST /admin/uploads is reachable by
* editors, who have no business changing the site's identity.
*/
async function uploadBrandAsset(req, res) {
const { slot } = req.params
const rules = ASSET_RULES[slot]
if (!rules) {
if (req.file) await discardUpload(req.file)
return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
}
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
await discardUpload(req.file)
return res.status(400).json({ message: `${rules.label} must be a PNG image` })
}
if (req.file.size > rules.maxBytes) {
await discardUpload(req.file)
return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
}
const url = `/uploads/${req.file.filename}`
try {
// Read-modify-write the row: uploading a logo must not clear a hero the
// admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
// with one bad slot does not block setting another.
const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
const next = { ...current, [slot]: url }
await settings.set('brand_assets', JSON.stringify(next), req.user.id)
htmlShell.invalidate()
await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
return res.status(201).json({ url, brand_assets: next })
} catch (err) {
log.error('uploadBrandAsset', err)
// The row is the point of the call; a stored file nothing points at is
// litter, so it goes back out with the error.
await discardUpload(req.file)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Delete one settings row — the "reset to defaults" primitive.
//
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
@@ -576,6 +681,7 @@ async function deleteSetting(req, res) {
}
try {
await settings.remove(key)
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
await activity.log({ req, action: 'settings.reset', detail: { key } })
return res.json({ message: 'Setting reset to default' })
} catch (err) {
@@ -810,6 +916,8 @@ module.exports = {
getSettings,
updateSettings,
deleteSetting,
uploadBrandAsset,
ASSET_RULES,
listActivity,
listUsers,
createUser,

View File

@@ -12,6 +12,7 @@
const express = require('express')
const ctrl = require('./admin.controller')
const { upload } = require('./imageUpload')
const { requireRole } = require('../../../utils/auth')
const settingsRouter = express.Router()
@@ -41,6 +42,26 @@ settingsRouter.put(
adminOnly,
ctrl.updateSettings,
)
// Upload one brand asset (logo/hero/favicon) and point brand_assets at it in the
// same call — see the controller for why it is one call and not "upload, then
// PUT". Uses the shared multer config (one upload directory, one mimetype
// allowlist); the per-slot PNG rule and size caps are applied in the handler.
settingsRouter.post(
'/brand-asset/:slot',
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Upload a brand asset and set it as the override (admin only)'
// #swagger.description = 'Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.parameters['slot'] = { in: 'path', required: true, description: 'Which asset to replace', schema: { type: 'string', enum: ['logo', 'hero', 'favicon'] } } */
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
/* #swagger.responses[201] = { description: 'Stored file URL and the updated overrides', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", example: "/uploads/1712345678901-ab12cd34.png" }, brand_assets: { type: "object", properties: { logo: { type: "string" }, hero: { type: "string" }, favicon: { type: "string" } } } } } } } } */
/* #swagger.responses[400] = { description: 'No file, unknown slot, disallowed type, or over the slot size cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
upload.single('image'),
ctrl.uploadBrandAsset,
)
// Reset one setting to its default by deleting the row. Only the keys whose
// default lives outside the store (theming, nav, hero draft) are deletable —
// the controller holds the allowlist.