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

@@ -292,6 +292,16 @@ export const api = {
// keys and the hero draft only (the server holds the allowlist). Idempotent, // keys and the hero draft only (the server holds the allowlist). Idempotent,
// so the caller need not know whether a row exists. // so the caller need not know whether a row exists.
resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }), resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
// Upload one brand asset (logo | hero | favicon) and set it as the override
// in the same call → { url, brand_assets }. A separate endpoint from the
// generic upload above because the server applies per-slot rules (favicons
// are PNG-only and capped small) and writes the settings row itself, so an
// upload never leaves a file nothing points at.
uploadBrandAsset: (slot, file) => {
const fd = new FormData()
fd.append('image', file)
return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true })
},
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
botActivity: () => req('/admin/bot-activity'), botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),

View File

@@ -0,0 +1,33 @@
import { useSite } from '../contexts/SiteContext.jsx'
// The instance logo, shown beside the MoonDot wherever the site says its own
// name (docs/website/THEMING_AND_NAV.md phase 5).
//
// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded
// override or BRAND_LOGO, and its default is the empty string. That is what
// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone
// exactly as it does now, and the logo is an addition an operator opts into.
//
// It sits beside the moon rather than replacing it. The moon is the app's own
// mark and appears on surfaces (maintenance, login) that must render before the
// settings fetch resolves; swapping it out would leave those momentarily blank.
//
// Deliberately not used for the footer's "powered by Runic Gateway" emblem
// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and
// must not follow brand_assets (§4.11).
export default function BrandLogo({ height = 22, alt = '', style }) {
const { brand, siteTitle } = useSite()
if (!brand.logo) return null
return (
<img
src={brand.logo}
// Decorative by default: every call site puts the site title in text right
// next to it, so alt text here would have a screen reader say the name
// twice. A caller that renders the logo alone passes its own alt.
alt={alt || ''}
aria-hidden={alt ? undefined : true}
title={siteTitle}
style={{ height, width: 'auto', maxWidth: height * 6, objectFit: 'contain', display: 'block', ...style }}
/>
)
}

View File

@@ -1,5 +1,6 @@
import { Link, NavLink } from 'react-router-dom' import { Link, NavLink } from 'react-router-dom'
import MoonDot from './MoonDot.jsx' import MoonDot from './MoonDot.jsx'
import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
@@ -68,6 +69,7 @@ export default function SiteHeader() {
className="display" className="display"
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
> >
<BrandLogo height={22} />
<MoonDot /> <MoonDot />
{siteTitle} {siteTitle}
</Link> </Link>

View File

@@ -8,11 +8,16 @@ const SiteContext = createContext(null)
export function SiteProvider({ children }) { export function SiteProvider({ children }) {
const [settings, setSettings] = useState({}) const [settings, setSettings] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Whether a fetch has actually SUCCEEDED, as distinct from `loading` — which
// also goes false when the request failed and we fell back to {}. The boot
// theme handoff below turns on this distinction.
const [settled, setSettled] = useState(false)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const data = await api.publicSettings() const data = await api.publicSettings()
setSettings(data || {}) setSettings(data || {})
setSettled(true)
} catch { } catch {
setSettings({}) setSettings({})
} finally { } finally {
@@ -33,7 +38,17 @@ export function SiteProvider({ children }) {
const appliedTokens = useRef([]) const appliedTokens = useRef([])
useEffect(() => { useEffect(() => {
appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current) appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current)
}, [settings.theme]) // Take over from the shell's boot block. The server injects the same tokens
// into <head> so a themed instance does not paint the shipped palette for a
// frame first (utils/htmlShell.js); from here on this effect is the
// authority, and leaving the block behind would mean a later reset removed
// the inline properties only to reveal the stale block underneath.
//
// Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request
// leaves us with no theme at all, and dropping the block then would strip a
// themed instance back to the shipped palette for no reason.
if (settled) document.getElementById('theme-boot')?.remove()
}, [settings.theme, settled])
// Apply the instance accent color to the CSS variable the theme is built on, // Apply the instance accent color to the CSS variable the theme is built on,
// so branding flows to every `var(--accent)` at runtime (no rebuild). This is // so branding flows to every `var(--accent)` at runtime (no rebuild). This is

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
@@ -231,6 +232,7 @@ export default function AdminLayout() {
}} }}
> >
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
<BrandLogo height={24} />
<MoonDot /> <MoonDot />
<div> <div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}> <div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx' import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
@@ -181,6 +182,10 @@ export default function AdminLogin() {
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}> <div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
{/* Stacked above the moon rather than beside it: this layout is
centered text, and a flex row here would change the block's
height on instances with no logo. */}
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
<MoonDot size={15} glow={0.55} /> <MoonDot size={15} glow={0.55} />
</div> </div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}> <h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>

View File

@@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
import BrandAssetsPanel from './BrandAssetsPanel.jsx'
// Admin · Appearance — the theme half of docs/website/THEMING_AND_NAV.md // Admin · Appearance — the theme and brand-asset halves of
// (phases 3-4). Brand asset uploads and the nav builder are phases 5 and 7 and // docs/website/THEMING_AND_NAV.md (phases 3-5). The nav builder is phase 7 and
// get their own screens. // gets its own screen.
// //
// Two things shape this form: // Two things shape this form:
// //
@@ -66,6 +67,10 @@ export default function AppearanceAdmin() {
// "this instance is using the shipped theme" note — an admin needs to be able // "this instance is using the shipped theme" note — an admin needs to be able
// to tell "never themed" from "themed to look like the default". // to tell "never themed" from "themed to look like the default".
const [stored, setStored] = useState(false) const [stored, setStored] = useState(false)
// The brand-asset overrides, read in the same settings fetch and then owned by
// the panel below (its uploads save on their own, so it does not share this
// screen's Save button).
const [assets, setAssets] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -88,6 +93,15 @@ export default function AppearanceAdmin() {
parsed = null parsed = null
} }
setStored(Boolean(all.theme_visual)) setStored(Boolean(all.theme_visual))
// Same fail-safe parse as the theme: a malformed row reads as absent, so
// the panel shows the env defaults rather than an error.
let parsedAssets = null
try {
parsedAssets = all.brand_assets ? JSON.parse(all.brand_assets) : null
} catch {
parsedAssets = null
}
setAssets(parsedAssets && typeof parsedAssets === 'object' && !Array.isArray(parsedAssets) ? parsedAssets : {})
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === 'object') {
setPreset(parsed.preset || 'runic-gateway') setPreset(parsed.preset || 'runic-gateway')
setCustom({ setCustom({
@@ -340,6 +354,9 @@ export default function AppearanceAdmin() {
The accent reaches the mobile app and the Discord bot too both theme themselves from this The accent reaches the mobile app and the Discord bot too both theme themselves from this
sites public branding. sites public branding.
</p> </p>
{/* ── Brand assets ───────────────────────────────────────── */}
<BrandAssetsPanel initial={assets || {}} />
</section> </section>
) )
} }

View File

@@ -0,0 +1,213 @@
import { useRef, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Admin · Appearance → Brand assets (docs/website/THEMING_AND_NAV.md §6.3).
//
// Three slots, each an override layer over the matching BRAND_* env value. An
// empty slot is not "no image" — it is "whatever this instance was deployed
// with", which is why every row shows what it currently resolves to rather than
// an empty box.
//
// Unlike the theme form above, an upload SAVES IMMEDIATELY: the file and the
// settings row are written by one request, because an upload that stored a file
// and then waited for a Save press would leave litter in /uploads whenever the
// admin changed their mind. Clearing a slot is the same deal in reverse.
const SLOTS = [
{
id: 'logo',
label: 'Logo',
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
limit: '1 MB',
envVar: 'BRAND_LOGO',
help: 'Shown beside the moon in the site header, the admin sidebar and the player portal, and used as the link preview image when a page is shared.',
},
{
id: 'hero',
label: 'Hero image',
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
limit: '8 MB',
envVar: 'BRAND_HERO',
// §4.9: the hero editor's own background beats this, and an admin who does
// not know that files a bug against a working system.
help: 'The image behind the portal hero. If the hero editor has its own background image set, that wins over this one.',
},
{
id: 'favicon',
label: 'Favicon',
accept: 'image/png',
limit: '512 KB',
envVar: 'BRAND_FAVICON',
// §4.10: .ico would mean adding a type to the upload allowlist, and the
// stored extension coming from that allowlist is what makes uploads safe.
help: 'The browser tab icon. PNG only — a 32×32 or 64×64 square works everywhere.',
},
]
export default function BrandAssetsPanel({ initial }) {
const { brand, refresh: refreshSite } = useSite()
const [assets, setAssets] = useState(initial || {})
const [busySlot, setBusySlot] = useState('')
const [error, setError] = useState('')
const inputs = useRef({})
async function upload(slot, file) {
if (!file) return
setBusySlot(slot)
setError('')
try {
const res = await api.admin.uploadBrandAsset(slot, file)
setAssets(res.brand_assets || {})
await refreshSite()
} catch (err) {
setError(err.message || 'Could not upload that image.')
} finally {
setBusySlot('')
// Let the same file be picked again after a failure — a file input holds
// its value, so re-choosing it would fire no change event.
if (inputs.current[slot]) inputs.current[slot].value = ''
}
}
async function clear(slot) {
setBusySlot(slot)
setError('')
try {
const next = { ...assets }
delete next[slot]
// Clearing the last override deletes the row rather than storing `{}` —
// absence of the row is what selects the env defaults (§2), and a stored
// empty object would be a different state that means the same thing.
if (Object.keys(next).length) await api.admin.updateSettings({ brand_assets: next })
else await api.admin.resetSetting('brand_assets')
setAssets(next)
await refreshSite()
} catch (err) {
setError(err.message || 'Could not clear that asset.')
} finally {
setBusySlot('')
}
}
return (
<div>
<span className="field-label">Brand assets</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 8 }}>
{SLOTS.map((slot) => {
const overridden = Boolean(assets[slot.id])
// What the site actually uses right now: the override, or the env
// value the brand block already resolved for us.
const effective = assets[slot.id] || brand[slot.id] || ''
return (
<div
key={slot.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 14,
padding: 12,
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
}}
>
<div
style={{
width: 76,
height: 48,
flex: '0 0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--line-soft)',
borderRadius: 6,
background: 'var(--bg-deep)',
overflow: 'hidden',
}}
>
{effective ? (
<img src={effective} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
) : (
<span className="sans dim" style={{ fontSize: '0.68rem' }}>
none
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ fontSize: '0.86rem', color: 'var(--ink)' }}>
{slot.label}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', lineHeight: 1.6, marginTop: 2 }}>
{slot.help}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 6 }}>
{overridden ? (
<>
Uploaded override <code>{assets[slot.id]}</code>
</>
) : effective ? (
<>
Using the deployed default from <code>{slot.envVar}</code>
</>
) : (
<>
Not set <code>{slot.envVar}</code> is empty, so nothing is rendered
</>
)}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
<input
ref={(el) => {
inputs.current[slot.id] = el
}}
type="file"
accept={slot.accept}
disabled={Boolean(busySlot)}
onChange={(e) => upload(slot.id, e.target.files?.[0])}
className="sans"
style={{ fontSize: '0.74rem', maxWidth: 240 }}
aria-label={`Upload a ${slot.label.toLowerCase()}`}
/>
<span className="sans dim" style={{ fontSize: '0.7rem' }}>
max {slot.limit}
</span>
{overridden && (
<button
type="button"
onClick={() => clear(slot.id)}
disabled={Boolean(busySlot)}
className="sans"
title={`Go back to ${slot.envVar}`}
style={linkBtn}
>
{busySlot === slot.id ? 'working…' : 'clear'}
</button>
)}
</div>
</div>
</div>
)
})}
</div>
{error && (
<span className="sans" style={{ display: 'block', marginTop: 8, color: '#d98b84', fontSize: '0.85rem' }}>
{error}
</span>
)}
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
Uploads apply as soon as they finish there is nothing to save here. The footers powered by
Runic Gateway mark is the projects badge, not this instances, and never changes.
</span>
</div>
)
}
const linkBtn = {
border: 'none',
background: 'transparent',
color: 'var(--accent)',
fontSize: '0.72rem',
cursor: 'pointer',
padding: 0,
}

View File

@@ -1,5 +1,6 @@
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
@@ -86,6 +87,7 @@ export default function PlayerPortalLayout() {
}} }}
> >
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
<BrandLogo height={24} />
<MoonDot /> <MoonDot />
<div> <div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}> <div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
// Centered card layout shared by the player login / register pages. `subtitle` // Centered card layout shared by the player login / register pages. `subtitle`
@@ -25,6 +26,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}> <div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
{/* Stacked above the moon rather than beside it: this layout is
centered text, and a flex row here would change the block's
height on instances with no logo. */}
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
<MoonDot size={15} glow={0.55} /> <MoonDot size={15} glow={0.55} />
</div> </div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}> <h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
export default function Maintenance() { export default function Maintenance() {
@@ -28,6 +29,7 @@ export default function Maintenance() {
> >
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}> <div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
<div style={{ marginBottom: 26 }}> <div style={{ marginBottom: 26 }}>
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
<MoonDot size={18} glow={0.6} /> <MoonDot size={18} glow={0.6} />
</div> </div>
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}> <p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>

View File

@@ -625,6 +625,16 @@
"requireAuth" "requireAuth"
] ]
}, },
{
"method": "POST",
"path": "/api/v1/admin/settings/brand-asset/:slot",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"multerMiddleware"
]
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/admin/shard/account", "path": "/api/v1/admin/shard/account",

View File

@@ -253,6 +253,10 @@
"method": "DELETE", "method": "DELETE",
"path": "/api/v1/admin/settings/:key" "path": "/api/v1/admin/settings/:key"
}, },
{
"method": "POST",
"path": "/api/v1/admin/settings/brand-asset/:slot"
},
{ {
"method": "POST", "method": "POST",
"path": "/api/v1/admin/shard/account" "path": "/api/v1/admin/shard/account"

View File

@@ -16,6 +16,7 @@ const brand = require('./config/brand')
const csp = require('./config/csp') const csp = require('./config/csp')
const { cspReportLimiter } = require('./middleware/rateLimit') const { cspReportLimiter } = require('./middleware/rateLimit')
const createLogger = require('./utils/logger') const createLogger = require('./utils/logger')
const htmlShell = require('./utils/htmlShell')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy') const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore') const botScore = require('./middleware/botScore')
@@ -96,31 +97,6 @@ const htmlEscape = (s) =>
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]), (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 // Uploaded images — always served, even during maintenance. Force nosniff so a
// stored file is never interpreted as anything other than its declared type // 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 // (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'))) { if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
// Serve a branded copy of the index.html shell for every SPA route; assets keep // Serve a branded copy of the index.html shell for every SPA route; assets keep
// their own cache-friendly static handler. // 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.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 { } else {
app.get('*', (req, res) => app.get('*', (req, res) =>
res res

View File

@@ -2,6 +2,7 @@ const settingsDb = require('./settings.db')
const brand = require('../../config/brand') const brand = require('../../config/brand')
const { parseJsonSetting } = require('../../utils/settingsJson') const { parseJsonSetting } = require('../../utils/settingsJson')
const { resolveThemeTokens } = require('../../utils/themeResolve') const { resolveThemeTokens } = require('../../utils/themeResolve')
const { resolveBrandAssets } = require('../../utils/brandAssets')
// Keys safe to expose on the public site. // Keys safe to expose on the public site.
const PUBLIC_KEYS = [ const PUBLIC_KEYS = [
@@ -158,10 +159,11 @@ async function getPublic() {
// site actually paints. See THEMING_AND_NAV.md §6. // site actually paints. See THEMING_AND_NAV.md §6.
const theme = resolveThemeTokens(all.theme_visual) const theme = resolveThemeTokens(all.theme_visual)
if (theme) out.theme = theme if (theme) out.theme = theme
// Uploaded brand-asset overrides (§6.3). Written by the Phase 5 admin UI; // Uploaded brand-asset overrides (§6.3), resolved here so every consumer of
// resolved here so every consumer of the brand block — the SPA, the Android // the brand block — the SPA, the Android app, the Discord bot — picks them up
// app, the Discord bot — picks them up through the one contract. // through the one contract. Forgiving on read like the theme: a slot holding
const brandAssets = parseJsonSetting(all.brand_assets) || {} // 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 — // Instance branding (BRAND_* env defaults). The admin-editable settings —
// site title, contact email, and now the theme accent and uploaded assets — // site title, contact email, and now the theme accent and uploaded assets —
// override the env value when set, so existing installs keep their // override the env value when set, so existing installs keep their
@@ -199,6 +201,28 @@ async function getPublic() {
return out 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 // The two nav-override keys their own audiences need but cannot read from
// GET /admin/settings (admin-only, while AdminLayout renders for editors and // GET /admin/settings (admin-only, while AdminLayout renders for editors and
// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md // moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md
@@ -230,6 +254,7 @@ module.exports = {
setMany, setMany,
getAll, getAll,
getPublic, getPublic,
getShellBrand,
getNav, getNav,
getInstanceName, getInstanceName,
PUBLIC_KEYS, PUBLIC_KEYS,

View File

@@ -1,3 +1,5 @@
const fs = require('fs')
const posts = require('../../../model/posts/posts.model') const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model') const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model') const settings = require('../../../model/settings/settings.model')
@@ -11,6 +13,8 @@ const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml') const { cleanBody } = require('../../../utils/sanitizeHtml')
const { parseJsonSetting } = require('../../../utils/settingsJson') const { parseJsonSetting } = require('../../../utils/settingsJson')
const { validateThemeVisual } = require('../../../utils/themeResolve') const { validateThemeVisual } = require('../../../utils/themeResolve')
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
const htmlShell = require('../../../utils/htmlShell')
const log = require('../../../utils/logger')('admin') 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 }) if (!check.ok) return res.status(400).json({ message: check.message })
updates.theme_visual = JSON.stringify(parsed) 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 { try {
await settings.setMany(updates, req.user.id) 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) } }) await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
return res.json(await settings.getAll()) return res.json(await settings.getAll())
} catch (err) { } 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. // Delete one settings row — the "reset to defaults" primitive.
// //
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the // For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
@@ -576,6 +681,7 @@ async function deleteSetting(req, res) {
} }
try { try {
await settings.remove(key) await settings.remove(key)
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
await activity.log({ req, action: 'settings.reset', detail: { key } }) await activity.log({ req, action: 'settings.reset', detail: { key } })
return res.json({ message: 'Setting reset to default' }) return res.json({ message: 'Setting reset to default' })
} catch (err) { } catch (err) {
@@ -810,6 +916,8 @@ module.exports = {
getSettings, getSettings,
updateSettings, updateSettings,
deleteSetting, deleteSetting,
uploadBrandAsset,
ASSET_RULES,
listActivity, listActivity,
listUsers, listUsers,
createUser, createUser,

View File

@@ -12,6 +12,7 @@
const express = require('express') const express = require('express')
const ctrl = require('./admin.controller') const ctrl = require('./admin.controller')
const { upload } = require('./imageUpload')
const { requireRole } = require('../../../utils/auth') const { requireRole } = require('../../../utils/auth')
const settingsRouter = express.Router() const settingsRouter = express.Router()
@@ -41,6 +42,26 @@ settingsRouter.put(
adminOnly, adminOnly,
ctrl.updateSettings, 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 // 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 — // default lives outside the store (theming, nav, hero draft) are deletable —
// the controller holds the allowlist. // the controller holds the allowlist.

View 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 }

View 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 }

View File

@@ -3532,6 +3532,132 @@
} }
} }
}, },
"/api/v1/admin/settings/brand-asset/{slot}": {
"post": {
"tags": [
"Admin · Settings"
],
"summary": "Upload a brand asset and set it as the override (admin only)",
"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.",
"parameters": [
{
"name": "slot",
"in": "path",
"required": true,
"schema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"logo",
"hero",
"favicon"
],
"items": {
"type": "string"
}
}
}
},
"description": "Which asset to replace"
}
],
"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"
}
}
}
}
}
}
}
},
"400": {
"description": "No file, unknown slot, disallowed type, or over the slot size cap",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"image": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}
},
"/api/v1/admin/settings/{key}": { "/api/v1/admin/settings/{key}": {
"delete": { "delete": {
"tags": [ "tags": [

View File

@@ -0,0 +1,352 @@
// Point the DB at a closed port BEFORE the pool is built, and the upload
// directory at a throwaway one BEFORE imageUpload.js resolves it — both are read
// at require time. Every model call is monkeypatched, so no query runs.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const os = require('os')
const path = require('path')
const fs = require('fs')
const UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-brand-assets-'))
process.env.UPLOAD_DIR = UPLOAD_DIR
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Phase 5 of docs/website/THEMING_AND_NAV.md: the brand-asset overrides. Two
// halves are worth locking — what a stored value is allowed to be (these values
// are written straight into HTML as URLs) and the upload route's per-slot rules,
// which tighten the shared allowlist without ever widening it (§9).
const { startApp } = require('./_helper')
const { isSafeAssetPath, validateBrandAssets, resolveBrandAssets, SLOTS } = require('../src/utils/brandAssets')
const settingsRouter = require('../src/router/v1/admin/settings.router')
const settingsDb = require('../src/model/settings/settings.db')
const sessionService = require('../src/auth/session.service')
const { requireAuth } = require('../src/auth/session.middleware')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const htmlShell = require('../src/utils/htmlShell')
const db = require('../src/utils/db')
after(() => {
db.close()
fs.rmSync(UPLOAD_DIR, { recursive: true, force: true })
})
const originals = {
validateSession: sessionService.validateSession,
isSessionRevoked: sessionService.isSessionRevoked,
sessionMeta: sessionService.sessionMeta,
getById: users.getById,
get: settingsDb.get,
set: settingsDb.set,
log: activity.log,
}
afterEach(() => {
Object.assign(sessionService, {
validateSession: originals.validateSession,
isSessionRevoked: originals.isSessionRevoked,
sessionMeta: originals.sessionMeta,
})
users.getById = originals.getById
settingsDb.get = originals.get
settingsDb.set = originals.set
activity.log = originals.log
})
function signInAs(user) {
sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' })
sessionService.isSessionRevoked = async () => false
sessionService.sessionMeta = () => ({})
users.getById = async () => user
activity.log = async () => {}
}
// ── What a stored asset path may be ───────────────────────────────────
test('only same-origin paths under the directories this server serves are accepted', () => {
for (const ok of ['/uploads/1-a.png', '/brand/logo.svg', '/assets/img/runic-emblem.png']) {
assert.equal(isSafeAssetPath(ok), true, `${ok} should be accepted`)
}
const rejected = [
'https://evil.example/x.png', // off-origin: an <img src> the operator did not choose
'//evil.example/x.png', // protocol-relative — looks like a path, loads off-origin
'javascript:alert(1)', // no scheme survives the prefix check, but be explicit
'/uploads/../../etc/passwd', // climbing out of the served directory
'/uploads/a b.png', // whitespace is the raw material for smuggling
'/uploads/"onerror="alert(1)', // quote would break out of the attribute
'/etc/passwd', // a path, but not one we serve
'uploads/1-a.png', // relative to the current route, not to the origin
'',
null,
42,
]
for (const bad of rejected) {
assert.equal(isSafeAssetPath(bad), false, `${String(bad)} should be rejected`)
}
})
// Strict on write: the admin gets told which field is wrong, rather than saving
// something that silently never renders.
test('a write naming an unknown slot or an unusable path is rejected by field', () => {
assert.equal(validateBrandAssets({ logo: '/uploads/a.png', hero: null }).ok, true)
assert.equal(validateBrandAssets(null).ok, true) // clearing every slot
const unknown = validateBrandAssets({ banner: '/uploads/a.png' })
assert.equal(unknown.ok, false)
assert.match(unknown.message, /banner/)
const offsite = validateBrandAssets({ favicon: 'https://evil.example/f.png' })
assert.equal(offsite.ok, false)
assert.match(offsite.message, /favicon/)
assert.equal(validateBrandAssets(['/uploads/a.png']).ok, false)
})
// Forgiving on read: one hand-edited slot must not cost the admin the other two.
test('a bad stored slot is dropped and its neighbours are kept', () => {
const resolved = resolveBrandAssets({ logo: '/uploads/a.png', hero: 'https://evil.example/h.png', favicon: null })
assert.deepEqual(resolved, { logo: '/uploads/a.png' })
})
test('resolve is also how a cleared slot stops being stored', () => {
// '' and null are how the UI clears a slot; neither may survive into the row,
// or "the field is absent" would stop being the single meaning of "use env".
assert.deepEqual(resolveBrandAssets({ logo: '', hero: null }), {})
assert.deepEqual(resolveBrandAssets(null), {})
assert.deepEqual(SLOTS, ['logo', 'hero', 'favicon'])
})
// ── POST /admin/settings/brand-asset/:slot ────────────────────────────
// A 1x1 PNG and a 1x1 GIF, small enough to inline and real enough for multer to
// accept by mimetype (which is what the shared allowlist keys off).
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')
function form(buffer, { filename = 'x.png', type = 'image/png' } = {}) {
const fd = new FormData()
fd.append('image', new Blob([buffer], { type }), filename)
return fd
}
const startSettingsApp = () =>
startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
const filesInUploadDir = () => fs.readdirSync(UPLOAD_DIR)
test('uploading a slot stores the file and points brand_assets at it', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.get = async () => null // never set before
let stored = null
settingsDb.set = async (key, value) => {
stored = { key, value }
}
const before = filesInUploadDir().length
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
method: 'POST',
body: form(PNG),
})
assert.equal(res.status, 201)
const body = await res.json()
assert.match(body.url, /^\/uploads\/\d+-[0-9a-f]{16}\.png$/)
assert.deepEqual(body.brand_assets, { logo: body.url })
assert.equal(stored.key, 'brand_assets')
assert.deepEqual(JSON.parse(stored.value), { logo: body.url })
assert.equal(filesInUploadDir().length, before + 1, 'the file is kept')
} finally {
await app.close()
}
})
// §6.3: uploading a logo does not force the admin to also pick a hero — and must
// not silently discard the hero they picked last week.
test('an upload merges into the existing overrides rather than replacing them', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.get = async () => JSON.stringify({ hero: '/uploads/existing-hero.png' })
let stored = null
settingsDb.set = async (key, value) => {
stored = value
}
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
method: 'POST',
body: form(PNG),
})
assert.equal(res.status, 201)
const saved = JSON.parse(stored)
assert.equal(saved.hero, '/uploads/existing-hero.png', 'the hero survives')
assert.match(saved.favicon, /^\/uploads\//)
} finally {
await app.close()
}
})
// §4.10: .ico would mean adding a type to MIME_EXT, and the stored extension
// coming from that map is what makes the upload path safe. PNG only, and the
// rejected file does not stay on disk.
test('a favicon that is not a PNG is refused and the file is discarded', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.set = async () => assert.fail('a refused upload must not write the row')
const before = filesInUploadDir().length
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
method: 'POST',
body: form(GIF, { filename: 'x.gif', type: 'image/gif' }),
})
assert.equal(res.status, 400)
assert.match((await res.json()).message, /PNG/)
assert.equal(filesInUploadDir().length, before, 'no orphan file left behind')
} finally {
await app.close()
}
})
test('a file over the slot cap is refused and discarded', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.set = async () => assert.fail('a refused upload must not write the row')
// Valid PNG header, then padding past the favicon's 512 KB cap — the shared
// multer limit is 8 MB, so only the per-slot rule can reject this.
const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
const before = filesInUploadDir().length
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
method: 'POST',
body: form(big),
})
assert.equal(res.status, 400)
assert.match((await res.json()).message, /512 KB or smaller/)
assert.equal(filesInUploadDir().length, before)
} finally {
await app.close()
}
})
test('the same file is accepted for a slot with a bigger cap', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.get = async () => null
settingsDb.set = async () => {}
const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/hero`, {
method: 'POST',
body: form(big),
})
assert.equal(res.status, 201)
} finally {
await app.close()
}
})
test('an unknown slot is refused and the file is discarded', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.set = async () => assert.fail('an unknown slot must not write the row')
const before = filesInUploadDir().length
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/banner`, {
method: 'POST',
body: form(PNG),
})
assert.equal(res.status, 400)
assert.equal(filesInUploadDir().length, before)
} finally {
await app.close()
}
})
// The generic POST /admin/uploads is reachable by editors. The site's identity
// is not theirs to change, so this route carries the same admin gate as the
// settings row it writes.
test('an editor cannot upload a brand asset', async () => {
signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' })
settingsDb.set = async () => assert.fail('an editor must not write brand_assets')
const before = filesInUploadDir().length
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
method: 'POST',
body: form(PNG),
})
assert.equal(res.status, 403)
assert.equal(filesInUploadDir().length, before, 'the gate runs before multer writes')
} finally {
await app.close()
}
})
// ── PUT /admin/settings { brand_assets } — how a slot is CLEARED ──────
//
// There is no per-slot delete route: clearing the logo is a write of the
// remaining slots, and clearing the last one is the existing reset-by-delete.
test('clearing a slot through the settings write drops it from the row', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
let stored = null
settingsDb.set = async (key, value) => {
stored = value
}
settingsDb.getAll = async () => []
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brand_assets: { logo: '/uploads/a.png', hero: null, favicon: '' } }),
})
assert.equal(res.status, 200)
assert.deepEqual(JSON.parse(stored), { logo: '/uploads/a.png' }, 'no null fields survive into the row')
} finally {
await app.close()
}
})
test('a settings write carrying an off-origin asset URL is rejected by field', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.set = async () => assert.fail('an invalid brand_assets must not be stored')
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brand_assets: { logo: 'https://tracker.example/pixel.png' } }),
})
assert.equal(res.status, 400)
assert.match((await res.json()).message, /brand_assets\.logo/)
} finally {
await app.close()
}
})
test('a successful upload invalidates the cached HTML shell', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.get = async () => null
settingsDb.set = async () => {}
let invalidated = 0
const realInvalidate = htmlShell.invalidate
htmlShell.invalidate = () => {
invalidated += 1
}
const app = await startSettingsApp()
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
method: 'POST',
body: form(PNG),
})
assert.equal(res.status, 201)
assert.equal(invalidated, 1, 'the favicon an admin just uploaded must not wait for the TTL')
} finally {
htmlShell.invalidate = realInvalidate
await app.close()
}
})

View File

@@ -0,0 +1,229 @@
// Point the DB at a closed port before the pool is built; the settings read is
// monkeypatched in every test that reaches it, so no query runs.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
// A brand URL, so the og:image absolutization of an uploaded path is exercised
// rather than being dead code in the test environment.
process.env.BRAND_URL = process.env.BRAND_URL || 'https://shard.example'
// BRAND_LOGO defaults to empty (no logo image rendered), which would make the
// "og:image still comes from env" assertions below pass vacuously.
process.env.BRAND_LOGO = process.env.BRAND_LOGO || '/brand/logo.png'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
// The cached, settings-aware HTML shell (docs/website/THEMING_AND_NAV.md §4.3).
// Three properties are load-bearing enough to lock here: that an untouched
// instance gets byte-for-byte the shell it got before this feature existed, that
// a DB fault still serves a page, and that the steady state is one cached string
// rather than a settings read per page view.
const htmlShell = require('../src/utils/htmlShell')
const settings = require('../src/model/settings/settings.model')
const brand = require('../src/config/brand')
const db = require('../src/utils/db')
after(() => db.close())
const originalGetShellBrand = settings.getShellBrand
afterEach(() => {
settings.getShellBrand = originalGetShellBrand
})
// A stand-in for the built client/dist/index.html: the two tags the shell
// rewrites plus the stylesheet link the theme block has to follow.
const TEMPLATE = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite App</title>
<meta name="description" content="placeholder" />
<link rel="stylesheet" href="/assets/index-abc123.css" />
</head>
<body><div id="root"></div></body>
</html>`
// The shell app.js served BEFORE this phase, reproduced verbatim. The point of
// the test is that this string and the new renderer's output are identical for
// an instance with no brand_assets and no theme_visual row (§9), so it is copied
// rather than imported.
function legacyRenderIndexHtml(html) {
const htmlEscape = (s) =>
String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]),
)
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>`)
}
// ── The byte-identical guarantee (§9) ─────────────────────────────────
test('with no overrides the shell is byte-identical to the pre-feature one', () => {
assert.equal(htmlShell.render(TEMPLATE, {}), legacyRenderIndexHtml(TEMPLATE))
})
test('an empty theme and empty assets are the same as no overrides at all', () => {
// A row that parsed to nothing usable resolves to null/undefined rather than
// to an empty block, or "reset" would leave a `<style>:root{}` behind forever.
assert.equal(htmlShell.render(TEMPLATE, { theme: null }), legacyRenderIndexHtml(TEMPLATE))
assert.equal(htmlShell.render(TEMPLATE, { theme: {} }), legacyRenderIndexHtml(TEMPLATE))
})
// ── Brand assets ──────────────────────────────────────────────────────
test('an uploaded favicon replaces the env one and touches nothing else', () => {
const html = htmlShell.render(TEMPLATE, { favicon: '/uploads/1-a.png' })
assert.match(html, /<link rel="icon" href="\/uploads\/1-a\.png" \/>/)
assert.ok(!html.includes(`href="${brand.favicon}"`), 'the env favicon is gone')
// §9: setting only the favicon changes the favicon only.
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
assert.equal(ogImage ? ogImage[1] : '', brand.logo, 'og:image still resolves from env')
})
test('an uploaded logo becomes og:image, absolutized against BRAND_URL', () => {
const html = htmlShell.render(TEMPLATE, { logo: '/uploads/2-b.png' })
assert.match(html, /<meta property="og:image" content="https:\/\/shard\.example\/uploads\/2-b\.png" \/>/)
})
test('an env logo is passed through untouched even when relative', () => {
// The shell an instance gets today is the operator's choice; only an uploaded
// path — which is always relative and is read off-site by scrapers — is made
// absolute. Anything else would break the byte-identical guarantee above.
const html = htmlShell.render(TEMPLATE, {})
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
assert.equal(ogImage ? ogImage[1] : '', brand.logo)
})
// ── The theme boot block (removes the first-paint flash) ───────────────
test('a resolved theme is emitted as a :root block after the stylesheet', () => {
const html = htmlShell.render(TEMPLATE, { theme: { '--accent': '#123456', '--bg': '#0b0f14' } })
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456;--bg:#0b0f14\}<\/style>/)
// Custom properties are equal-specificity, so the later block wins: it must
// come after the built stylesheet or a themed instance would paint :root.
assert.ok(
html.indexOf('theme-boot') > html.indexOf('/assets/index-abc123.css'),
'the theme block follows the stylesheet link',
)
})
test('a token that could carry markup is dropped, not escaped into the block', () => {
const html = htmlShell.render(TEMPLATE, {
theme: { '--accent': '#123456', '--x': '</style><script>alert(1)</script>', 'color': 'red' },
})
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456\}<\/style>/)
assert.ok(!html.includes('alert(1)'), 'no injected markup survives')
assert.ok(!html.includes('color:red'), 'a non-custom-property name never reaches the block')
})
// ── Caching, invalidation and the DB-fault fallback ────────────────────
test('the shell is rendered once and then served from cache', async () => {
let reads = 0
settings.getShellBrand = async () => {
reads += 1
return { logo: brand.logo, favicon: '/uploads/cached.png', theme: null }
}
htmlShell.init(TEMPLATE)
const first = await htmlShell.get()
const second = await htmlShell.get()
assert.equal(reads, 1, 'a settings read per page view would put the DB on every route')
assert.equal(first, second)
assert.match(first, /\/uploads\/cached\.png/)
})
test('a burst of requests on a cold cache does one read', async () => {
let reads = 0
settings.getShellBrand = async () => {
reads += 1
await new Promise((r) => setTimeout(r, 5))
return { logo: brand.logo, favicon: brand.favicon, theme: null }
}
htmlShell.init(TEMPLATE)
await Promise.all([htmlShell.get(), htmlShell.get(), htmlShell.get()])
assert.equal(reads, 1)
})
test('invalidate() makes the next request re-read', async () => {
let favicon = '/uploads/old.png'
let reads = 0
settings.getShellBrand = async () => {
reads += 1
return { logo: brand.logo, favicon, theme: null }
}
htmlShell.init(TEMPLATE)
assert.match(await htmlShell.get(), /old\.png/)
favicon = '/uploads/new.png'
assert.match(await htmlShell.get(), /old\.png/, 'still cached until told otherwise')
htmlShell.invalidate()
assert.match(await htmlShell.get(), /new\.png/)
assert.equal(reads, 2)
})
test('the cache expires on its own, so a second process converges', async () => {
let favicon = '/uploads/old.png'
settings.getShellBrand = async () => ({ logo: brand.logo, favicon, theme: null })
htmlShell.init(TEMPLATE)
assert.match(await htmlShell.get(), /old\.png/)
// Nothing invalidates here: this is the worker that did NOT handle the write.
favicon = '/uploads/new.png'
const realNow = Date.now
Date.now = () => realNow() + htmlShell.TTL_MS + 1
try {
assert.match(await htmlShell.get(), /new\.png/)
} finally {
Date.now = realNow
}
})
test('a settings read that throws serves the env-only shell instead of failing', async () => {
settings.getShellBrand = async () => {
throw new Error('ER_CON_COUNT_ERROR')
}
htmlShell.init(TEMPLATE)
assert.equal(await htmlShell.get(), legacyRenderIndexHtml(TEMPLATE))
})
test('the fallback is cached too — an outage is not a query per page view', async () => {
let reads = 0
settings.getShellBrand = async () => {
reads += 1
throw new Error('down')
}
htmlShell.init(TEMPLATE)
await htmlShell.get()
await htmlShell.get()
assert.equal(reads, 1)
})
test('an invalidation during a render is not overwritten by the stale result', async () => {
let favicon = '/uploads/old.png'
settings.getShellBrand = async () => {
const value = favicon
await new Promise((r) => setTimeout(r, 10))
return { logo: brand.logo, favicon: value, theme: null }
}
htmlShell.init(TEMPLATE)
const inflight = htmlShell.get() // reads 'old'
favicon = '/uploads/new.png'
htmlShell.invalidate() // the write lands mid-render
await inflight
assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached')
})