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:
@@ -16,6 +16,7 @@ const brand = require('./config/brand')
|
||||
const csp = require('./config/csp')
|
||||
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||
const createLogger = require('./utils/logger')
|
||||
const htmlShell = require('./utils/htmlShell')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
|
||||
@@ -96,31 +97,6 @@ const htmlEscape = (s) =>
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
|
||||
// Template the built index.html <head> with instance branding (title, meta
|
||||
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
|
||||
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
|
||||
function renderIndexHtml(html) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
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)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.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>`)
|
||||
}
|
||||
|
||||
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
||||
// stored file is never interpreted as anything other than its declared type
|
||||
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
||||
@@ -204,9 +180,23 @@ if (fs.existsSync(BRAND_DIR)) {
|
||||
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
||||
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
||||
// their own cache-friendly static handler.
|
||||
const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
//
|
||||
// The shell is templated from BRAND_* env *and* the admin's brand_assets /
|
||||
// theme_visual rows, so it is rendered lazily and cached rather than built once
|
||||
// at boot: see utils/htmlShell.js for the caching, the invalidation and why a
|
||||
// DB fault still serves a page.
|
||||
htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
app.use(express.static(CLIENT_DIST, { index: false }))
|
||||
app.get('*', (req, res) => res.type('html').send(indexHtml))
|
||||
app.get('*', async (req, res, next) => {
|
||||
// htmlShell.get() swallows a settings-read failure itself; the try is for
|
||||
// anything unforeseen, since an async handler that rejects in Express 4
|
||||
// hangs the request instead of reaching the error handler below.
|
||||
try {
|
||||
res.type('html').send(await htmlShell.get())
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
app.get('*', (req, res) =>
|
||||
res
|
||||
|
||||
Reference in New Issue
Block a user