diff --git a/client/src/api/client.js b/client/src/api/client.js
index 57b22c0..4312872 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -292,6 +292,16 @@ export const api = {
// keys and the hero draft only (the server holds the allowlist). Idempotent,
// so the caller need not know whether a row exists.
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}`),
botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
diff --git a/client/src/components/BrandLogo.jsx b/client/src/components/BrandLogo.jsx
new file mode 100644
index 0000000..f91ed53
--- /dev/null
+++ b/client/src/components/BrandLogo.jsx
@@ -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 (
+
+ )
+}
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index a3c0341..5b1382f 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -1,5 +1,6 @@
import { Link, NavLink } from 'react-router-dom'
import MoonDot from './MoonDot.jsx'
+import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
@@ -68,6 +69,7 @@ export default function SiteHeader() {
className="display"
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
>
+
{siteTitle}
diff --git a/client/src/contexts/SiteContext.jsx b/client/src/contexts/SiteContext.jsx
index 71c4a2c..288d40b 100644
--- a/client/src/contexts/SiteContext.jsx
+++ b/client/src/contexts/SiteContext.jsx
@@ -8,11 +8,16 @@ const SiteContext = createContext(null)
export function SiteProvider({ children }) {
const [settings, setSettings] = useState({})
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 () => {
try {
const data = await api.publicSettings()
setSettings(data || {})
+ setSettled(true)
} catch {
setSettings({})
} finally {
@@ -33,7 +38,17 @@ export function SiteProvider({ children }) {
const appliedTokens = useRef([])
useEffect(() => {
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
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,
// so branding flows to every `var(--accent)` at runtime (no rebuild). This is
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 341773b..37e67f2 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
+import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
@@ -231,6 +232,7 @@ export default function AdminLayout() {
}}
>
+
diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx
index bd71039..d6492ef 100644
--- a/client/src/routes/admin/AdminLogin.jsx
+++ b/client/src/routes/admin/AdminLogin.jsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
+import BrandLogo from '../../components/BrandLogo.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
@@ -181,6 +182,10 @@ export default function AdminLogin() {
+ {/* 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. */}
+
diff --git a/client/src/routes/admin/views/AppearanceAdmin.jsx b/client/src/routes/admin/views/AppearanceAdmin.jsx
index 2e9908c..5e5f417 100644
--- a/client/src/routes/admin/views/AppearanceAdmin.jsx
+++ b/client/src/routes/admin/views/AppearanceAdmin.jsx
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
+import BrandAssetsPanel from './BrandAssetsPanel.jsx'
-// Admin · Appearance — the theme half of docs/website/THEMING_AND_NAV.md
-// (phases 3-4). Brand asset uploads and the nav builder are phases 5 and 7 and
-// get their own screens.
+// Admin · Appearance — the theme and brand-asset halves of
+// docs/website/THEMING_AND_NAV.md (phases 3-5). The nav builder is phase 7 and
+// gets its own screen.
//
// 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
// to tell "never themed" from "themed to look like the default".
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 [error, setError] = useState('')
const [busy, setBusy] = useState(false)
@@ -88,6 +93,15 @@ export default function AppearanceAdmin() {
parsed = null
}
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') {
setPreset(parsed.preset || 'runic-gateway')
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
site’s public branding.
+
+ {/* ── Brand assets ───────────────────────────────────────── */}
+
)
}
diff --git a/client/src/routes/admin/views/BrandAssetsPanel.jsx b/client/src/routes/admin/views/BrandAssetsPanel.jsx
new file mode 100644
index 0000000..72957f4
--- /dev/null
+++ b/client/src/routes/admin/views/BrandAssetsPanel.jsx
@@ -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 (
+
+ Brand assets
+
+ {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 (
+
+
+ {effective ? (
+
+ ) : (
+
+ none
+
+ )}
+
+
+
+
+ {slot.label}
+
+
+ {slot.help}
+
+
+ {overridden ? (
+ <>
+ Uploaded override — {assets[slot.id]}
+ >
+ ) : effective ? (
+ <>
+ Using the deployed default from {slot.envVar}
+ >
+ ) : (
+ <>
+ Not set — {slot.envVar} is empty, so nothing is rendered
+ >
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ Uploads apply as soon as they finish — there is nothing to save here. The footer’s “powered by
+ Runic Gateway” mark is the project’s badge, not this instance’s, and never changes.
+
+
diff --git a/client/src/routes/player/PlayerShell.jsx b/client/src/routes/player/PlayerShell.jsx
index b444054..5fca191 100644
--- a/client/src/routes/player/PlayerShell.jsx
+++ b/client/src/routes/player/PlayerShell.jsx
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
+import BrandLogo from '../../components/BrandLogo.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
// Centered card layout shared by the player login / register pages. `subtitle`
@@ -25,6 +26,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
+ {/* 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. */}
+
diff --git a/client/src/routes/public/Maintenance.jsx b/client/src/routes/public/Maintenance.jsx
index b6b0b5a..f939410 100644
--- a/client/src/routes/public/Maintenance.jsx
+++ b/client/src/routes/public/Maintenance.jsx
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
+import BrandLogo from '../../components/BrandLogo.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
export default function Maintenance() {
@@ -28,6 +29,7 @@ export default function Maintenance() {
>
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 = [
- ``,
- ``,
- '',
- brand.url ? `` : '',
- brand.logo ? `` : '',
- '',
- ``,
- ``,
- brand.favicon ? `` : '',
- ]
- .filter(Boolean)
- .join('\n ')
- return html
- .replace(/[\s\S]*?<\/title>/i, `${title}`)
- .replace(/()/i, `$1${desc}$2`)
- .replace(/<\/head>/i, ` ${tags}\n `)
-}
-
// 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
diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js
index db708c5..1fa816f 100644
--- a/server/src/model/settings/settings.model.js
+++ b/server/src/model/settings/settings.model.js
@@ -2,6 +2,7 @@ const settingsDb = require('./settings.db')
const brand = require('../../config/brand')
const { parseJsonSetting } = require('../../utils/settingsJson')
const { resolveThemeTokens } = require('../../utils/themeResolve')
+const { resolveBrandAssets } = require('../../utils/brandAssets')
// Keys safe to expose on the public site.
const PUBLIC_KEYS = [
@@ -158,10 +159,11 @@ async function getPublic() {
// site actually paints. See THEMING_AND_NAV.md §6.
const theme = resolveThemeTokens(all.theme_visual)
if (theme) out.theme = theme
- // Uploaded brand-asset overrides (§6.3). Written by the Phase 5 admin UI;
- // resolved here so every consumer of the brand block — the SPA, the Android
- // app, the Discord bot — picks them up through the one contract.
- const brandAssets = parseJsonSetting(all.brand_assets) || {}
+ // Uploaded brand-asset overrides (§6.3), resolved here so every consumer of
+ // the brand block — the SPA, the Android app, the Discord bot — picks them up
+ // through the one contract. Forgiving on read like the theme: a slot holding
+ // 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 —
// site title, contact email, and now the theme accent and uploaded assets —
// override the env value when set, so existing installs keep their
@@ -199,6 +201,28 @@ async function getPublic() {
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 ` : ''
+}
+
+/**
+ * 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}
+ */
+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 }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 7f862d8..31e95e3 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -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}": {
"delete": {
"tags": [
diff --git a/server/test/brandAssets.test.js b/server/test/brandAssets.test.js
new file mode 100644
index 0000000..773f62a
--- /dev/null
+++ b/server/test/brandAssets.test.js
@@ -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 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()
+ }
+})
diff --git a/server/test/htmlShell.test.js b/server/test/htmlShell.test.js
new file mode 100644
index 0000000..03b5b4d
--- /dev/null
+++ b/server/test/htmlShell.test.js
@@ -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 = `
+
+
+
+ Vite App
+
+
+
+
+`
+
+// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
+ )
+ const title = htmlEscape(brand.name)
+ const desc = htmlEscape(brand.description)
+ const tags = [
+ ``,
+ ``,
+ '',
+ brand.url ? `` : '',
+ brand.logo ? `` : '',
+ '',
+ ``,
+ ``,
+ brand.favicon ? `` : '',
+ ]
+ .filter(Boolean)
+ .join('\n ')
+ return html
+ .replace(/[\s\S]*?<\/title>/i, `${title}`)
+ .replace(/()/i, `$1${desc}$2`)
+ .replace(/<\/head>/i, ` ${tags}\n `)
+}
+
+// ── 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 `', 'color': 'red' },
+ })
+ assert.match(html, /