From ec0036ce6dd31be5936a88736c1f38e4616a8dc6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 7 Aug 2026 18:15:29 -0500 Subject: [PATCH 1/6] feat(theming): settings-store, nav merge util and radius tokens Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI, no consumer wiring, and an instance that never touches the new settings keys renders exactly as it does today. Phase 0 - settings store: - settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to default" primitive. Defaults for these keys live in BRAND_* env, theme.css and the hardcoded NAV arrays, so reset has to delete the row rather than store a copy of the default. Allowlisted to the five theming/nav keys plus hero_layout_draft, admin-only, idempotent. - GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout renders for editors and moderators and PlayerPortalLayout for players, and none of them can read GET /admin/settings, so without this their nav override would silently never apply. - A fifth router group for it: /public is anonymous, /admin/settings is adminOnly, /player is self-scoped data. This is configuration that needs a login. - parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so every JSON key arrives as a string; malformed or wrong-shaped reads as absent, never as an error and never half-applied. - theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and nav_player deliberately do not. Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation only: it can set label/order/hidden and (grouped navs) group, and nothing else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false cannot un-hide anything - the existing filters run afterward, unchanged, and remain the boundary. Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome and the two 50% circles stay literal. --shadow-card and --panel-grad were already tokens. Tests: 16 new server tests, 20 new client tests. The route-manifest guard now also asserts /settings/** sits behind requireAuth. Swagger and both route artifacts regenerated. Co-Authored-By: Claude --- client/src/lib/navOverrides.js | 138 +++++++++++ client/src/styles/theme.css | 62 +++-- client/test/navOverrides.test.js | 203 +++++++++++++++ server/routes.guards.json | 18 ++ server/routes.manifest.json | 8 + server/src/model/settings/settings.db.js | 10 +- server/src/model/settings/settings.model.js | 40 +++ .../src/router/v1/admin/admin.controller.js | 28 +++ server/src/router/v1/admin/settings.router.js | 17 ++ server/src/router/v1/settings/index.js | 33 +++ .../src/router/v1/settings/nav.controller.js | 17 ++ server/src/router/v1/settings/nav.router.js | 28 +++ server/src/router/v1/v1.router.js | 7 + server/src/utils/settingsJson.js | 35 +++ server/swagger/swagger-output.json | 171 +++++++++++++ server/swagger/swagger.js | 14 ++ server/test/routeManifest.test.js | 7 +- server/test/settingsTheming.test.js | 234 ++++++++++++++++++ 18 files changed, 1042 insertions(+), 28 deletions(-) create mode 100644 client/src/lib/navOverrides.js create mode 100644 client/test/navOverrides.test.js create mode 100644 server/src/router/v1/settings/index.js create mode 100644 server/src/router/v1/settings/nav.controller.js create mode 100644 server/src/router/v1/settings/nav.router.js create mode 100644 server/src/utils/settingsJson.js create mode 100644 server/test/settingsTheming.test.js diff --git a/client/src/lib/navOverrides.js b/client/src/lib/navOverrides.js new file mode 100644 index 0000000..beb70f2 --- /dev/null +++ b/client/src/lib/navOverrides.js @@ -0,0 +1,138 @@ +// Apply an admin's stored navigation overrides to a hardcoded NAV array. +// +// The three navs (public header, admin sidebar, player portal) stay declared in +// code; this layer only reorders, relabels and hides what is already there. +// See docs/website/THEMING_AND_NAV.md §7. +// +// **This is presentation, never authorization.** The override can carry +// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else. +// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or +// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run +// *after* this merge, unchanged, and remain the actual boundary. An override +// saying `hidden: false` on a role-gated item still shows nothing to a viewer +// whose role check fails: hiding is subtractive here, never additive. +// +// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string +// label, a group that does not exist — is ignored rather than rejected, so a +// stale or hand-edited settings row degrades to the code default instead of +// rendering a broken nav. + +// Two shapes are supported, because two exist: +// flat [{ to, label, ... }] — public header, player portal +// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar +function isGrouped(nav) { + return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items)) +} + +// A stored override entry is usable only field by field: a bad `label` must not +// discard a good `order` alongside it. +function cleanEntry(raw, groupTitles) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + const out = {} + if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim() + if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order + if (raw.hidden === true) out.hidden = true + // `group` may only name a section the base nav already declares. Anything else + // — a renamed group, a typo, an invented category — is dropped, so an item can + // never land in a header that does not exist. + if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group + return out +} + +// Sort by effective order, where an item the admin never reordered keeps its +// index in the base array as its key. Two tie-breaks, in order: an explicit +// order beats a coincidental index (the admin said "first", so first), and two +// explicit orders stay in code order (the sort is stable). +// +// In practice the editor writes an order for every item in a list, the way +// drag-and-drop reordering does, so ties are the stale-row case rather than the +// normal one. They still have to resolve predictably. +function byOrder(items) { + return items + .map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined })) + .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit)) + .map(({ item }) => { + const { __order, ...rest } = item + return rest + }) +} + +// Apply label/hidden/order to one flat list. Returns visible items only, with +// the sort key parked on `__order` for byOrder to consume. +function mergeItems(items, entries) { + const out = [] + for (const item of items) { + const o = entries.get(item.to) + if (o?.hidden) continue + // Spread the base item first so `to`, `roles`, `feature`, `icon` and `end` + // survive verbatim — the override only ever lands on `label`. + out.push({ ...item, ...(o?.label ? { label: o.label } : {}), __order: o?.order }) + } + return out +} + +/** + * @param {Array} baseNav the hardcoded nav — the source of truth for `to`, + * `roles`, `feature`, `icon` and `end` + * @param {object|null} overrides the parsed settings JSON, keyed by `to`, or + * null when the admin never touched this nav + * @returns {Array} a new array of the same shape, or `baseNav` itself when there + * is nothing to apply + */ +export function applyNavOverrides(baseNav, overrides) { + if (!Array.isArray(baseNav)) return [] + // The untouched path, and the one that matters most: no row, a malformed row, + // or a row with nothing usable in it all render the nav exactly as coded. + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav + + const grouped = isGrouped(baseNav) + const groupTitles = new Set( + grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [], + ) + + // Keyed by `to`, and only for a `to` the base nav actually declares. An + // override for a route that no longer exists is dropped here, so deleting a + // route in code can never leave a dangling override that does something + // unexpected later. + const known = new Set( + grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to), + ) + const entries = new Map() + for (const [to, raw] of Object.entries(overrides)) { + if (!known.has(to)) continue + const entry = cleanEntry(raw, groupTitles) + if (entry && Object.keys(entry).length > 0) entries.set(to, entry) + } + if (entries.size === 0) return baseNav + + if (!grouped) return byOrder(mergeItems(baseNav, entries)) + + // Grouped: an item may also be moved into another *existing* titled section. + // Groups keep their coded order — only membership and within-group order move. + const moved = new Map() // destination title → items pulled in from elsewhere + const kept = baseNav.map((g) => { + const items = [] + for (const item of g.items) { + const o = entries.get(item.to) + if (o?.group && o.group !== g.title) { + if (!moved.has(o.group)) moved.set(o.group, []) + moved.get(o.group).push(item) + continue + } + items.push(item) + } + return { ...g, items } + }) + + return kept + .map((g) => ({ + ...g, + items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)), + })) + // A group whose every item was hidden must not leave an orphaned header. + // AdminLayout drops empty groups again after its own role filter; doing it + // here too keeps the util correct on its own. + .filter((g) => g.items.length > 0) +} + +export default applyNavOverrides diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css index 351ce14..a02c4be 100644 --- a/client/src/styles/theme.css +++ b/client/src/styles/theme.css @@ -24,6 +24,22 @@ --shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3); --panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b)); + + /* Corner radius, by the kind of surface rather than by the pixel value, so a + theme preset can restyle all of them at once (see + docs/website/THEMING_AND_NAV.md §4.7). Seeded at the values already in use + — this promotion is a no-op, and every existing instance must keep looking + exactly as it does today. + + Deliberately four tokens, not three: .card/.panel are 10px and .panel-flat + is 12px, so collapsing them would have restyled every card on every + install. The 7px (.rte-btn) and 6px (.rte-linkmenu-item) values stay + literals — interior editor chrome, not brand surface — as do the 50% + circles, which are shapes rather than radii. */ + --radius-pill: 999px; + --radius-panel: 12px; + --radius-card: 10px; + --radius-input: 8px; } * { @@ -99,7 +115,7 @@ a { flex-direction: column; padding: 24px; border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-card); text-decoration: none; color: var(--ink); background: var(--panel-grad); @@ -123,19 +139,19 @@ a.card:focus-visible { } .panel { border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-card); background: var(--panel-grad); } .panel-flat { border: 1px solid var(--line); - border-radius: 12px; + border-radius: var(--radius-panel); overflow: hidden; background: var(--panel-flat); } .note { border: 1px solid var(--line); border-left: 3px solid var(--accent); - border-radius: 8px; + border-radius: var(--radius-input); background: rgba(19, 36, 60, 0.4); padding: 18px 22px; color: var(--muted); @@ -168,7 +184,7 @@ a.card:focus-visible { /* ===== Pills / buttons ===== */ .pill { border: 1px solid var(--line); - border-radius: 999px; + border-radius: var(--radius-pill); padding: 7px 14px; color: var(--muted); background: rgba(11, 22, 48, 0.5); @@ -186,7 +202,7 @@ a.card:focus-visible { outline: none; } .btn { - border-radius: 999px; + border-radius: var(--radius-pill); padding: 12px 26px; font-family: var(--sans); font-size: 0.92rem; @@ -214,7 +230,7 @@ a.card:focus-visible { background: var(--blue); } .btn-sq { - border-radius: 8px; + border-radius: var(--radius-input); padding: 10px 18px; font-size: 0.85rem; } @@ -230,7 +246,7 @@ button[disabled] { .select { width: 100%; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); padding: 11px 14px; background: var(--bg); color: var(--ink); @@ -312,7 +328,7 @@ button[disabled] { } .prose img { max-width: 100%; - border-radius: 8px; + border-radius: var(--radius-input); border: 1px solid var(--line); } @@ -320,7 +336,7 @@ button[disabled] { .rte { position: relative; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--bg); } .rte:focus-within { @@ -407,7 +423,7 @@ button[disabled] { width: min(360px, calc(100% - 20px)); padding: 10px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--panel-a); box-shadow: var(--shadow-card); } @@ -449,7 +465,7 @@ button[disabled] { display: inline-block; padding: 3px 10px; border: 1px solid var(--line); - border-radius: 999px; + border-radius: var(--radius-pill); background: rgba(127, 153, 189, 0.1); color: var(--accent); font-family: var(--sans); @@ -494,7 +510,7 @@ button[disabled] { width: 100%; padding: 8px 10px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--panel-flat); color: var(--text); text-align: left; @@ -532,7 +548,7 @@ button[disabled] { overflow-y: auto; padding: 12px 14px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--bg); } .diff-add { @@ -603,7 +619,7 @@ button[disabled] { vertical-align: middle; } .badge { - border-radius: 999px; + border-radius: var(--radius-pill); padding: 3px 11px; font-size: 0.72rem; font-weight: 700; @@ -780,7 +796,7 @@ button[disabled] { } .page-image img { max-width: 100%; - border-radius: 8px; + border-radius: var(--radius-input); border: 1px solid var(--line); display: block; } @@ -863,7 +879,7 @@ button[disabled] { } .pb-column-editor { border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); padding: 12px; background: var(--panel-flat, transparent); } @@ -881,7 +897,7 @@ button[disabled] { } .pb-subblock { border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); padding: 10px; margin-top: 10px; background: var(--bg); @@ -919,7 +935,7 @@ button[disabled] { border: 1px solid #6e3b38; background: rgba(110, 59, 56, 0.16); color: #e6a9a3; - border-radius: 8px; + border-radius: var(--radius-input); padding: 10px 14px; margin-top: 14px; font-size: 0.86rem; @@ -928,7 +944,7 @@ button[disabled] { border: 1px solid var(--accent); background: var(--blue); color: var(--accent-bright); - border-radius: 8px; + border-radius: var(--radius-input); padding: 8px 14px; margin-top: 14px; font-size: 0.86rem; @@ -960,7 +976,7 @@ button[disabled] { gap: 8px; padding: 12px; border: 1px dashed var(--line); - border-radius: 10px; + border-radius: var(--radius-card); margin-bottom: 16px; } .pb-canvas { @@ -970,7 +986,7 @@ button[disabled] { } .pb-block-card { border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-card); background: var(--panel-flat, transparent); } .pb-block-card.is-dragging { @@ -1082,7 +1098,7 @@ button[disabled] { border: 1px solid var(--accent); background: var(--blue); color: var(--accent-bright); - border-radius: 8px; + border-radius: var(--radius-input); padding: 8px 14px; margin-bottom: 20px; font-size: 0.85rem; diff --git a/client/test/navOverrides.test.js b/client/test/navOverrides.test.js new file mode 100644 index 0000000..027397a --- /dev/null +++ b/client/test/navOverrides.test.js @@ -0,0 +1,203 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { applyNavOverrides } from '../src/lib/navOverrides.js' + +// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece +// of this feature with real correctness risk, so it is tested in isolation from +// React. Two properties matter above all others: +// +// 1. No override, or a useless one, renders the coded nav untouched. +// 2. The override cannot add a route, cannot touch a role/feature gate, and +// cannot un-hide anything. It is presentation only. + +const FLAT = [ + { label: 'Home', to: '/', end: true }, + { label: 'News', to: '/site/news' }, + { label: 'Wiki', to: '/wiki' }, + { label: 'Shard', to: '/site/shard', feature: 'status' }, +] + +const GROUPED = [ + { items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] }, + { + title: 'Content', + items: [ + { to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] }, + { to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] }, + ], + }, + { + title: 'System', + items: [ + { to: '/admin/settings', label: 'Settings', roles: ['admin'] }, + { to: '/admin/users', label: 'Users', roles: ['admin'] }, + ], + }, +] + +const labels = (nav) => nav.map((i) => i.label) +const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)]) + +// ── The untouched path ──────────────────────────────────────────────────── + +// Most instances will never set these keys. Absence must be a true no-op, and +// cheap: the same array reference back means no needless re-render either. +test('no override returns the base nav unchanged', () => { + for (const overrides of [null, undefined, '', 0, [], 'not an object']) { + assert.equal(applyNavOverrides(FLAT, overrides), FLAT) + } +}) + +test('an override with nothing usable in it returns the base nav unchanged', () => { + assert.equal(applyNavOverrides(FLAT, {}), FLAT) + // Every field here is unusable: unknown route, blank label, non-numeric order, + // hidden as a string rather than the boolean true. + assert.equal( + applyNavOverrides(FLAT, { + '/does/not/exist': { label: 'Ghost', hidden: true }, + '/wiki': { label: ' ', order: 'first', hidden: 'yes' }, + }), + FLAT, + ) +}) + +// ── The security boundary ───────────────────────────────────────────────── + +// The single most important negative case: the override layer must never be a +// way to introduce a route into a nav. +test('an unknown `to` is ignored, never added', () => { + const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } }) + assert.equal(out.length, FLAT.length) + assert.ok(!out.some((i) => i.to === '/admin/secret')) +}) + +test('roles, feature, icon, end and to survive the merge verbatim', () => { + const out = applyNavOverrides(FLAT, { + '/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' }, + }) + const shard = out.find((i) => i.to === '/site/shard') + assert.equal(shard.label, 'Server Status') // the one thing an override may set + assert.equal(shard.feature, 'status') // gate untouched + assert.equal(shard.roles, undefined) // and not invented + assert.ok(!out.some((i) => i.to === '/evil')) +}) + +test('hidden:false cannot un-hide anything — hiding is subtractive only', () => { + // The item is still present after the merge; whether it renders is decided by + // the caller's own role/feature filter, which this layer cannot reach. + const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } }) + assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone') +}) + +// ── Flat navs: label, order, hidden ─────────────────────────────────────── + +test('label overrides only the labelled item', () => { + const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } }) + assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard']) +}) + +test('hidden drops the item', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } }) + assert.deepEqual(labels(out), ['Home', 'News', 'Shard']) +}) + +// An item the admin never reordered keeps its position in the coded array, so +// setting one order does not scramble the rest. +test('order moves one item and leaves the others in code order', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } }) + assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard']) +}) + +test('two items given the same order keep their code order (stable sort)', () => { + const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } }) + // News before Wiki — the tie resolves to the coded order, not to insertion + // order in the settings JSON. Both precede Home, whose 0 is only its index. + assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard']) +}) + +// An explicit order and an untouched item's index share one number line, so +// they can collide. "Put this first" has to actually mean first. +test('an explicit order beats an untouched item that merely sits at that index', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } }) + assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard']) +}) + +test('the merge does not mutate the base nav', () => { + const before = JSON.stringify(FLAT) + applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } }) + assert.equal(JSON.stringify(FLAT), before) +}) + +test('no internal sort key leaks into the returned items', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } }) + for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered') +}) + +// ── Grouped (admin) navs ────────────────────────────────────────────────── + +test('label and order apply within a group', () => { + const out = applyNavOverrides(GROUPED, { + '/admin/wiki': { label: 'Knowledge Base', order: 0 }, + }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Knowledge Base', 'Posts']], + ['System', ['Settings', 'Users']], + ]) +}) + +test('group moves an item into another existing section', () => { + const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Posts', 'Wiki', 'Users']], + ['System', ['Settings']], + ]) +}) + +// A group that does not exist must not conjure a header. Groups are chosen from +// a dropdown of existing titles in the editor; this is the stale-row guard. +test('a group that is not an existing title is ignored', () => { + const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Posts', 'Wiki']], + ['System', ['Settings', 'Users']], + ]) +}) + +test('a moved item can be ordered in its new group', () => { + const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } }) + assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']]) +}) + +test('hiding every item in a group leaves no orphaned header', () => { + const out = applyNavOverrides(GROUPED, { + '/admin/settings': { hidden: true }, + '/admin/users': { hidden: true }, + }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Posts', 'Wiki']], + ]) +}) + +test('group ordering itself is not overridable — sections stay in code order', () => { + const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } }) + assert.deepEqual( + out.map((g) => g.title ?? null), + [null, 'Content', 'System'], + ) +}) + +// ── Degenerate input ────────────────────────────────────────────────────── + +test('a non-array base nav yields an empty nav rather than throwing', () => { + assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), []) + assert.deepEqual(applyNavOverrides(undefined, null), []) +}) + +test('an empty base nav stays empty', () => { + assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), []) +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index c7c6c5e..24c25f0 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -616,6 +616,15 @@ "requireAuth" ] }, + { + "method": "DELETE", + "path": "/api/v1/admin/settings/:key", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "POST", "path": "/api/v1/admin/shard/account", @@ -2136,6 +2145,15 @@ "gates": [ "siteMode" ] + }, + { + "method": "GET", + "path": "/api/v1/settings/nav", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] } ], "internal": [ diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 1692271..ad7d5d3 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -249,6 +249,10 @@ "method": "PUT", "path": "/api/v1/admin/settings" }, + { + "method": "DELETE", + "path": "/api/v1/admin/settings/:key" + }, { "method": "POST", "path": "/api/v1/admin/shard/account" @@ -892,6 +896,10 @@ { "method": "GET", "path": "/api/v1/public/wiki/tags" + }, + { + "method": "GET", + "path": "/api/v1/settings/nav" } ], "internal": [ diff --git a/server/src/model/settings/settings.db.js b/server/src/model/settings/settings.db.js index 012493d..07c4547 100644 --- a/server/src/model/settings/settings.db.js +++ b/server/src/model/settings/settings.db.js @@ -22,4 +22,12 @@ async function seedDefault(key, value) { await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value]) } -module.exports = { getAll, get, set, seedDefault } +// Delete a settings row. "Reset to defaults" for the theming/nav keys is the +// *absence* of a row, not a stored copy of the defaults — see +// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a +// no-op, so reset is idempotent. +async function remove(key) { + await query('DELETE FROM settings WHERE `key` = ?', [key]) +} + +module.exports = { getAll, get, set, seedDefault, remove } diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 6c4b61e..a2bd505 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -10,8 +10,27 @@ const PUBLIC_KEYS = [ 'contact_email', 'site_title', 'hero_layout', // portal hero composition (JSON). Draft key stays admin-only. + 'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1. + 'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3. + 'nav_public', // public site nav overrides (JSON). §6.4. ] +// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md). +// All five are JSON strings and all five are ABSENT by default — no migration +// seeds them. Absence of the row, not an empty value, is what makes a surface +// fall back to BRAND_* env / the hardcoded theme.css / the hardcoded NAV arrays. +// +// nav_admin and nav_player are deliberately not public: an anonymous visitor has +// no use for either, and the admin nav's labels describe the shape of the admin +// surface. They are read by their owners through GET /api/v1/settings/nav (§4.2). +const THEMING_KEYS = ['theme_visual', 'brand_assets', 'nav_public', 'nav_admin', 'nav_player'] + +// Keys a reset may delete. An explicit allowlist, not "any key": DELETE on an +// arbitrary key would let a bad request drop site_mode or the uo-link config, +// whose absence means something else entirely. hero_layout_draft is included +// because discarding a draft is the same operation. +const DELETABLE_KEYS = [...THEMING_KEYS, 'hero_layout_draft'] + // Player self-registration mode. Stored under the 'player_registration' key. // NOTE: the raw value is never exposed publicly — getPublic() derives boolean // availability flags from it instead (see below). @@ -96,6 +115,10 @@ async function set(key, value, updatedBy = null) { return settingsDb.set(key, value, updatedBy) } +async function remove(key) { + return settingsDb.remove(key) +} + async function setMany(obj, updatedBy = null) { for (const [key, value] of Object.entries(obj)) { await settingsDb.set(key, value, updatedBy) @@ -155,6 +178,19 @@ async function getPublic() { return out } +// The two nav-override keys their own audiences need but cannot read from +// GET /admin/settings (admin-only, while AdminLayout renders for editors and +// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md +// §4.2). Values are returned as stored: raw JSON strings, or null when the +// admin never overrode that nav. +async function getNav() { + const all = await getAll() + return { + nav_admin: all.nav_admin ?? null, + nav_player: all.nav_player ?? null, + } +} + // The client-facing ntfy base URL (no trailing slash), or null when unset. function publicNtfyUrl() { const explicit = (process.env.NTFY_PUBLIC_URL || '').trim() @@ -169,11 +205,15 @@ function publicNtfyUrl() { module.exports = { get, set, + remove, setMany, getAll, getPublic, + getNav, getInstanceName, PUBLIC_KEYS, + THEMING_KEYS, + DELETABLE_KEYS, REGISTRATION_KEY, REGISTRATION_MODES, getRegistrationMode, diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index d7707cb..d80ce14 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -539,6 +539,33 @@ async function updateSettings(req, res) { } } +// Delete one settings row — the "reset to defaults" primitive. +// +// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the +// hardcoded NAV arrays; the *absence* of the row is what selects them +// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not +// store a copy of the defaults, or the next change to a default would not reach +// an instance that had ever pressed reset. +// +// The key allowlist is the point of the route: an unrestricted DELETE would let +// a stray request drop site_mode or the uo-link config, where absence means +// something else entirely. Deleting a key that is not set succeeds — reset is +// idempotent and the UI should not have to know whether a row exists. +async function deleteSetting(req, res) { + const { key } = req.params + if (!settings.DELETABLE_KEYS.includes(key)) { + return res.status(400).json({ message: 'Setting is not resettable' }) + } + try { + await settings.remove(key) + await activity.log({ req, action: 'settings.reset', detail: { key } }) + return res.json({ message: 'Setting reset to default' }) + } catch (err) { + log.error('deleteSetting', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // ── Activity log ────────────────────────────────────────────────────── async function listActivity(req, res) { const limit = Math.min(Number(req.query.limit) || 50, 200) @@ -764,6 +791,7 @@ module.exports = { deleteWikiCategory, getSettings, updateSettings, + deleteSetting, listActivity, listUsers, createUser, diff --git a/server/src/router/v1/admin/settings.router.js b/server/src/router/v1/admin/settings.router.js index 626cce1..37b4ac8 100644 --- a/server/src/router/v1/admin/settings.router.js +++ b/server/src/router/v1/admin/settings.router.js @@ -41,5 +41,22 @@ settingsRouter.put( adminOnly, ctrl.updateSettings, ) +// Reset one setting to its default by deleting the row. Only the keys whose +// default lives outside the store (theming, nav, hero draft) are deletable — +// the controller holds the allowlist. +settingsRouter.delete( + '/:key', + // #swagger.tags = ['Admin · Settings'] + // #swagger.summary = 'Reset one setting to its default (admin only)' + // #swagger.description = 'Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.parameters['key'] = { in: 'path', required: true, description: 'Settings key to reset', schema: { type: 'string' } } */ + /* #swagger.responses[200] = { description: 'Setting reset', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[400] = { description: 'Setting is not resettable', 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, + ctrl.deleteSetting, +) module.exports = settingsRouter diff --git a/server/src/router/v1/settings/index.js b/server/src/router/v1/settings/index.js new file mode 100644 index 0000000..2bf8c72 --- /dev/null +++ b/server/src/router/v1/settings/index.js @@ -0,0 +1,33 @@ +// /api/v1/settings — settings any *authenticated* account needs to read, whoever +// they are. +// +// A fifth group alongside /auth, /public, /admin and /player, and deliberately +// not folded into any of them: +// +// - /public is anonymous, and the admin nav's labels describe the shape of the +// admin surface — that belongs behind a login. +// - /admin is `staffOnly` + `requireRole('admin')` on settings, but AdminLayout +// renders for editors and moderators too, so they could never read their own +// nav overrides from there (docs/website/THEMING_AND_NAV.md §4.2). +// - /player is self-service data scoped to req.user.id. These rows are +// site-wide configuration that happens to need a login, not anything about +// the caller. +// +// Group gate: authenticated only, no role restriction — staff and players alike +// read their own layout's nav. It lives here, ahead of every mount, so a route +// added later cannot ship ungated. + +const express = require('express') + +const { requireAuth } = require('../../../auth/session.middleware') +const noindex = require('../../../middleware/noindex') + +const navRouter = require('./nav.router') + +const settingsRouter = express.Router() + +settingsRouter.use(noindex, requireAuth) + +settingsRouter.use('/nav', navRouter) + +module.exports = settingsRouter diff --git a/server/src/router/v1/settings/nav.controller.js b/server/src/router/v1/settings/nav.controller.js new file mode 100644 index 0000000..249ad23 --- /dev/null +++ b/server/src/router/v1/settings/nav.controller.js @@ -0,0 +1,17 @@ +const settings = require('../../../model/settings/settings.model') +const log = require('../../../utils/logger') + +// The nav overrides for the two authenticated layouts. Values are the raw stored +// JSON strings (settings.value is TEXT) or null; the caller parses them with the +// same fail-safe posture as every other JSON setting — malformed reads as +// absent, and absent means the hardcoded NAV array is used unchanged. +async function getNav(req, res) { + try { + return res.json(await settings.getNav()) + } catch (err) { + log.error('getNav', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getNav } diff --git a/server/src/router/v1/settings/nav.router.js b/server/src/router/v1/settings/nav.router.js new file mode 100644 index 0000000..d06d853 --- /dev/null +++ b/server/src/router/v1/settings/nav.router.js @@ -0,0 +1,28 @@ +// Settings · Nav — the admin-sidebar and player-portal nav overrides, readable +// by the accounts those navs are rendered for. +// +// Mounted at /api/v1/settings/nav by settings/index.js, which already applied +// `noindex, requireAuth`. No role gate on purpose: an editor, a moderator and a +// player each need the override for the layout they see, and the payload is +// presentation-only — label/order/hidden/group over items the reader's own +// role/feature filter still gets the final say on +// (docs/website/THEMING_AND_NAV.md §7). + +const express = require('express') + +const ctrl = require('./nav.controller') + +const navRouter = express.Router() + +navRouter.get( + '/', + // #swagger.tags = ['Settings'] + // #swagger.summary = 'Nav overrides for the admin and player layouts' + // #swagger.description = 'Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Nav overrides', content: { "application/json": { schema: { $ref: "#/components/schemas/NavSettings" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.getNav, +) + +module.exports = navRouter diff --git a/server/src/router/v1/v1.router.js b/server/src/router/v1/v1.router.js index d354db9..eddc566 100644 --- a/server/src/router/v1/v1.router.js +++ b/server/src/router/v1/v1.router.js @@ -6,11 +6,18 @@ const authRouter = require('./auth') const publicRouter = require('./public') const adminRouter = require('./admin') const playerRouter = require('./player') +const settingsRouter = require('./settings') v1Router.use('/auth', authRouter) v1Router.use('/public', publicRouter) v1Router.use('/admin', adminRouter) v1Router.use('/player', playerRouter) +// Site-wide settings that need a login but no particular role — currently the +// nav overrides the admin and player layouts read for themselves. Not /public +// (the admin nav's labels describe the admin surface), not /admin (editors and +// moderators render AdminLayout but are not admins), not /player (this is +// configuration, not self-scoped data). See settings/index.js. +v1Router.use('/settings', settingsRouter) // NOTE: /internal is intentionally NOT mounted here. Those routes return the // decrypted Discord bot token and must never share the public listener that // Pangolin proxies. They live on a separate, unpublished port via diff --git a/server/src/utils/settingsJson.js b/server/src/utils/settingsJson.js new file mode 100644 index 0000000..524315a --- /dev/null +++ b/server/src/utils/settingsJson.js @@ -0,0 +1,35 @@ +// Parse a JSON-valued settings row. +// +// `settings.value` is TEXT (db/schema.sql), so every JSON-shaped key — +// hero_layout, and now theme_visual / brand_assets / nav_* — is stored +// stringified and arrives as a string. Consumers must parse it, and the parse +// has to be fail-safe: a malformed or wrong-shaped value is treated as +// **absent** (the surface falls back to its BRAND_* env / theme.css / NAV +// default), never as an error and never as a half-applied object. That is the +// same posture parseLayout already takes on the client +// (client/src/lib/heroLayout.js). +// +// See docs/website/THEMING_AND_NAV.md §4.4. + +/** + * @param {string|null|undefined} str the raw stored value + * @param {(value: unknown) => boolean} [validator] shape check; anything it + * rejects is treated as absent + * @returns {object|null} the parsed object, or null when absent/malformed + */ +function parseJsonSetting(str, validator) { + if (typeof str !== 'string' || str === '') return null + let parsed + try { + parsed = JSON.parse(str) + } catch { + return null + } + // Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to + // every consumer of these keys as a syntax error is. + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + if (validator && !validator(parsed)) return null + return parsed +} + +module.exports = { parseJsonSetting } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 766d02e..dd221ff 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -60,6 +60,10 @@ "name": "Player · Appeals", "description": "Player-submitted moderation appeals" }, + { + "name": "Settings", + "description": "Site-wide settings any authenticated account may read (nav overrides)" + }, { "name": "Admin · Dashboard", "description": "Dashboard summary and site mode" @@ -3528,6 +3532,79 @@ } } }, + "/api/v1/admin/settings/{key}": { + "delete": { + "tags": [ + "Admin · Settings" + ], + "summary": "Reset one setting to its default (admin only)", + "description": "Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Settings key to reset" + } + ], + "responses": { + "200": { + "description": "Setting reset", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Setting is not resettable", + "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": [] + } + ] + } + }, "/api/v1/admin/shard/account": { "post": { "tags": [ @@ -12650,6 +12727,51 @@ } } } + }, + "/api/v1/settings/nav": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Nav overrides for the admin and player layouts", + "description": "Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.", + "responses": { + "200": { + "description": "Nav overrides", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NavSettings" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -17801,6 +17923,55 @@ } } }, + "NavSettings": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav." + }, + "properties": { + "type": "object", + "properties": { + "nav_admin": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "{\"/admin/posts\":{\"label\":\"Blog Posts\",\"order\":10}}" + } + } + }, + "nav_player": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": {} + } + } + } + } + } + }, "DeletedId": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index d1d6ba7..450e979 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -59,6 +59,7 @@ const doc = { { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' }, { name: 'Player · Appeals', description: 'Player-submitted moderation appeals' }, + { name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' }, { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' }, @@ -778,6 +779,19 @@ const doc = { }, additionalProperties: true, }, + NavSettings: { + type: 'object', + description: + 'Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav.', + properties: { + nav_admin: { + type: 'string', + nullable: true, + example: '{"/admin/posts":{"label":"Blog Posts","order":10}}', + }, + nav_player: { type: 'string', nullable: true, example: null }, + }, + }, // Delete/mutation acknowledgements — each echoes the affected resource key // or a boolean flag rather than a { message } string. DeletedId: { diff --git a/server/test/routeManifest.test.js b/server/test/routeManifest.test.js index e23484b..312ac9d 100644 --- a/server/test/routeManifest.test.js +++ b/server/test/routeManifest.test.js @@ -51,15 +51,14 @@ test('the manifest only inventories API surface, never static mounts', () => { } }) -test('every /admin and /player route still sits behind the shared auth gate', () => { +test('every /admin, /player and /settings route still sits behind the shared auth gate', () => { // Router-level `use()` gates do not appear in an individual route's own stack, so a // capability router extracted from admin.routes.js without re-applying the gate would // silently publish authenticated endpoints. Names are only a hint — `requireRole(...)` // returns an anonymous arrow and cannot be seen here — but a *missing* requireAuth is // unambiguous. - const gated = collected.public.filter( - (r) => r.path.startsWith('/api/v1/admin/') || r.path.startsWith('/api/v1/player/'), - ) + const AUTHENTICATED_GROUPS = ['/api/v1/admin/', '/api/v1/player/', '/api/v1/settings/'] + const gated = collected.public.filter((r) => AUTHENTICATED_GROUPS.some((p) => r.path.startsWith(p))) assert.ok(gated.length > 100, 'expected the gated surface to be found') for (const route of gated) { assert.ok( diff --git a/server/test/settingsTheming.test.js b/server/test/settingsTheming.test.js new file mode 100644 index 0000000..b8db7eb --- /dev/null +++ b/server/test/settingsTheming.test.js @@ -0,0 +1,234 @@ +// Point the DB at a closed port BEFORE anything builds the pool. Every model +// call below is monkeypatched, so no query runs; db.close() releases the pool so +// the process exits cleanly. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Phase 0 of the admin theming & navigation feature +// (docs/website/THEMING_AND_NAV.md): the settings-store groundwork the rest of +// the feature is built on. Three things are load-bearing enough to lock here — +// the reset-by-delete allowlist, who may read the nav overrides, and the +// fail-safe JSON parse — plus the guarantee that registering the new keys did +// not change what an untouched instance serves. +const { startApp } = require('./_helper') +const settingsRouter = require('../src/router/v1/admin/settings.router') +const navSettingsRouter = require('../src/router/v1/settings') +const settingsDb = require('../src/model/settings/settings.db') +const settings = require('../src/model/settings/settings.model') +const { parseJsonSetting } = require('../src/utils/settingsJson') +const sessionService = require('../src/auth/session.service') +// The admin group applies `noindex, isLoggedIn, staffOnly` before mounting the +// settings router, and requireRole reads the req.user that requireAuth attaches. +// Mounting the router bare would 403 every caller for the wrong reason. +const { requireAuth } = require('../src/auth/session.middleware') +const users = require('../src/model/users/users.model') +const activity = require('../src/model/activity/activity.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +const originals = { + validateSession: sessionService.validateSession, + isSessionRevoked: sessionService.isSessionRevoked, + sessionMeta: sessionService.sessionMeta, + getById: users.getById, + getAll: settingsDb.getAll, + remove: settingsDb.remove, + set: settingsDb.set, + log: activity.log, +} +afterEach(() => { + Object.assign(sessionService, { + validateSession: originals.validateSession, + isSessionRevoked: originals.isSessionRevoked, + sessionMeta: originals.sessionMeta, + }) + users.getById = originals.getById + settingsDb.getAll = originals.getAll + settingsDb.remove = originals.remove + activity.log = originals.log +}) + +// Sign every request in as the given DB user (role decides the gate outcome). +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 () => {} +} + +// ── DELETE /admin/settings/:key — reset is delete, and only for some keys ── + +test('resetting a theming key deletes its row', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + const deleted = [] + settingsDb.remove = async (key) => deleted.push(key) + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + for (const key of settings.THEMING_KEYS) { + const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' }) + assert.equal(res.status, 200, `${key} should be resettable`) + } + assert.deepEqual(deleted, settings.THEMING_KEYS) + } finally { + await app.close() + } +}) + +// The whole "no migration seeds defaults" principle (§2) rests on this: reset +// must not write a stored copy of the defaults, or a later change to a default +// would never reach an instance that once pressed reset. +test('reset never writes a value, only deletes', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.remove = async () => {} + settingsDb.set = () => assert.fail('reset must not write a settings row') + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' }) + assert.equal(res.status, 200) + } finally { + settingsDb.set = originals.set + await app.close() + } +}) + +// An unrestricted DELETE would let a stray request drop site_mode or the +// uo-link config, where an absent row means something else entirely. +test('a key outside the allowlist is rejected and nothing is deleted', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.remove = async () => assert.fail('must not delete a non-resettable key') + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + for (const key of ['site_mode', 'uo_link_token', 'player_registration', 'hero_layout']) { + const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' }) + assert.equal(res.status, 400, `${key} must not be resettable`) + } + } finally { + await app.close() + } +}) + +// Reset is idempotent: the UI resets without first knowing whether a row exists. +test('resetting a key that was never set still succeeds', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.remove = async () => {} // DELETE of a missing row affects 0 rows + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/nav_public`, { method: 'DELETE' }) + assert.equal(res.status, 200) + } finally { + await app.close() + } +}) + +test('reset is admin-only — an editor is refused', async () => { + signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' }) + settingsDb.remove = async () => assert.fail('an editor must not reset a setting') + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' }) + assert.equal(res.status, 403) + } finally { + await app.close() + } +}) + +// ── GET /settings/nav — the reason this endpoint exists at all ───────────── + +// AdminLayout renders for editors and moderators, PlayerPortalLayout for +// players, and none of them can read GET /admin/settings. Without this route +// their nav override would silently never apply (§4.2). +for (const role of ['admin', 'editor', 'moderator', 'player']) { + test(`GET /settings/nav is readable by an authenticated ${role}`, async () => { + signInAs({ id: 7, username: 'u', role, status: 'active' }) + settingsDb.getAll = async () => [ + { key: 'nav_admin', value: '{"/admin/posts":{"label":"Blog Posts"}}' }, + { key: 'nav_player', value: '{"/portal/characters":{"hidden":true}}' }, + ] + const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/settings/nav`) + assert.equal(res.status, 200, `${role} should reach the handler, got ${res.status}`) + const body = await res.json() + assert.equal(body.nav_admin, '{"/admin/posts":{"label":"Blog Posts"}}') + assert.equal(body.nav_player, '{"/portal/characters":{"hidden":true}}') + } finally { + await app.close() + } + }) +} + +test('GET /settings/nav rejects an anonymous caller', async () => { + sessionService.validateSession = () => null + const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/settings/nav`) + assert.equal(res.status, 401) + } finally { + await app.close() + } +}) + +test('GET /settings/nav returns null for a nav that was never overridden', async () => { + signInAs({ id: 7, username: 'u', role: 'player', status: 'active' }) + settingsDb.getAll = async () => [] + const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/settings/nav`) + assert.deepEqual(await res.json(), { nav_admin: null, nav_player: null }) + } finally { + await app.close() + } +}) + +// ── getPublic(): the new keys appear only when a row exists ─────────────── + +test('an untouched instance exposes none of the new keys publicly', async () => { + settingsDb.getAll = async () => [] + const pub = await settings.getPublic() + for (const key of settings.THEMING_KEYS) { + assert.equal(pub[key], undefined, `${key} must be absent, not empty`) + } +}) + +test('theme_visual / brand_assets / nav_public are public once set; nav_admin / nav_player never are', async () => { + settingsDb.getAll = async () => [ + { key: 'theme_visual', value: '{"preset":"modern"}' }, + { key: 'brand_assets', value: '{"logo":"/uploads/a.png"}' }, + { key: 'nav_public', value: '{"/news":{"order":1}}' }, + { key: 'nav_admin', value: '{"/admin/posts":{"hidden":true}}' }, + { key: 'nav_player', value: '{"/portal":{"label":"Home"}}' }, + ] + const pub = await settings.getPublic() + assert.equal(pub.theme_visual, '{"preset":"modern"}') + assert.equal(pub.brand_assets, '{"logo":"/uploads/a.png"}') + assert.equal(pub.nav_public, '{"/news":{"order":1}}') + // The admin nav's labels describe the shape of the admin surface, and an + // anonymous visitor has no use for either — they stay behind /settings/nav. + assert.equal(pub.nav_admin, undefined) + assert.equal(pub.nav_player, undefined) +}) + +// ── parseJsonSetting: malformed reads as absent, never as an error ───────── + +test('parseJsonSetting returns null for absent, empty and malformed values', () => { + for (const input of [null, undefined, '', '{', 'not json', '[]', '"str"', '4', 'null']) { + assert.equal(parseJsonSetting(input), null, `${JSON.stringify(input)} should read as absent`) + } +}) + +test('parseJsonSetting returns the parsed object for a well-formed value', () => { + assert.deepEqual(parseJsonSetting('{"preset":"modern"}'), { preset: 'modern' }) +}) + +// A wrong-shaped value must fall back to the default whole, never partially — +// half a theme applied is worse than no theme applied. +test('parseJsonSetting treats a validator rejection as absent', () => { + const isThemeVisual = (v) => typeof v.preset === 'string' + assert.equal(parseJsonSetting('{"custom":{}}', isThemeVisual), null) + assert.deepEqual(parseJsonSetting('{"preset":"fantasy"}', isThemeVisual), { preset: 'fantasy' }) +}) From 3d6b2e23a7f823ca2792ab46e7613437a6681629 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 7 Aug 2026 19:16:23 -0500 Subject: [PATCH 2/6] feat(theming): server-resolved theme engine and admin appearance UI Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font shortlist, and /admin/appearance to drive them. The design put the presets in theme.css as [data-theme] blocks. That does not work: SiteContext writes --accent as an inline style on , which beats any attribute-selector block, so a preset's accent would have been painted over by BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app themes itself from -- reported the other one. Presets now live in server/src/config/themePresets.js. themeResolve.js layers :root <- preset <- custom per field into a token map, getPublic() returns it as `theme`, and the client writes it onto . One authority for the merge, and brand.accent is by construction the accent the site paints. theme.css's :root is untouched, so an instance with no row gets no theme block and renders as today. Also: presets carry the full 15-token palette (eight would have left Fantasy with blue-grey borders); the option catalog is served from GET /settings/theme/options so the form cannot offer what the server rejects; validation is strict on write and forgiving on read; and the Discord bot now fetches the effective accent instead of its boot-time env copy. Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger factory rather than calling it, so a DB fault would have thrown a TypeError inside the catch instead of returning 500. Co-Authored-By: Claude --- bot/src/brand.js | 84 +++- bot/src/server.js | 5 + bot/src/site/siteApiClient.js | 11 +- client/index.html | 11 +- client/src/App.jsx | 12 + client/src/api/client.js | 12 + client/src/contexts/SiteContext.jsx | 24 +- client/src/lib/themeVars.js | 47 ++ client/src/routes/admin/AdminLayout.jsx | 3 + .../routes/admin/views/AppearanceAdmin.jsx | 354 ++++++++++++++ client/test/themeVars.test.js | 100 ++++ server/routes.guards.json | 9 + server/routes.manifest.json | 4 + server/src/config/themePresets.js | 215 +++++++++ server/src/model/settings/settings.model.js | 35 +- .../src/router/v1/admin/admin.controller.js | 18 + server/src/router/v1/settings/index.js | 2 + .../src/router/v1/settings/nav.controller.js | 7 +- .../router/v1/settings/theme.controller.js | 17 + server/src/router/v1/settings/theme.router.js | 26 ++ server/src/utils/themeResolve.js | 202 ++++++++ server/swagger/swagger-output.json | 434 +++++++++++++++++- server/swagger/swagger.js | 85 +++- server/test/publicBrand.test.js | 73 +++ server/test/settingsTheming.test.js | 89 ++++ server/test/themeResolve.test.js | 262 +++++++++++ 26 files changed, 2113 insertions(+), 28 deletions(-) create mode 100644 client/src/lib/themeVars.js create mode 100644 client/src/routes/admin/views/AppearanceAdmin.jsx create mode 100644 client/test/themeVars.test.js create mode 100644 server/src/config/themePresets.js create mode 100644 server/src/router/v1/settings/theme.controller.js create mode 100644 server/src/router/v1/settings/theme.router.js create mode 100644 server/src/utils/themeResolve.js create mode 100644 server/test/themeResolve.test.js diff --git a/bot/src/brand.js b/bot/src/brand.js index bfa72e3..a9e5b41 100644 --- a/bot/src/brand.js +++ b/bot/src/brand.js @@ -1,13 +1,83 @@ // Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and // logs carry the instance identity. Kept minimal — the bot only needs the name // and the accent color (as an int for discord.js embeds). +// +// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site +// changes `theme_visual`, which the server resolves into the effective +// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md +// §4.5). This process boots from env and then follows that value, so embeds +// don't stay the old color until someone restarts the container. +// +// Design constraints this satisfies: +// • env is always a working answer — a site that is down, unconfigured or +// mid-restart never costs the bot its accent, it just keeps the last known +// good one; +// • reading `brand.accentInt` never awaits and never throws, because it is +// read inline while building an embed; +// • at most one refresh is ever in flight. require('dotenv').config() -const name = process.env.BRAND_NAME || 'Runic Gateway' -const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd' -const accentInt = (() => { - const n = parseInt(String(accentHex).replace('#', ''), 16) - return Number.isNaN(n) ? 0x7f99bd : n -})() +const siteApi = require('./site/siteApiClient') +const createLogger = require('./utils/logger') -module.exports = { name, accentHex, accentInt } +const log = createLogger('brand') + +const name = process.env.BRAND_NAME || 'Runic Gateway' +const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd' + +function toInt(hex) { + const n = parseInt(String(hex).replace('#', ''), 16) + return Number.isNaN(n) ? 0x7f99bd : n +} + +// How long a fetched accent is trusted before the next read triggers a refresh. +// A theme change reaching Discord within ten minutes is fine; a network call per +// embed is not. +const TTL_MS = 10 * 60 * 1000 + +let accentHex = ENV_ACCENT +let accentInt = toInt(ENV_ACCENT) +let fetchedAt = 0 +let inFlight = null + +async function fetchAccent() { + const res = await siteApi.getPublicSettings() + // Any failure — site down, maintenance, malformed body — leaves the current + // value in place. Stamping fetchedAt regardless is deliberate: it stops a + // persistently unreachable site from firing a request on every single read. + fetchedAt = Date.now() + const accent = res.ok ? res.data?.brand?.accent : null + if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return + if (accent === accentHex) return + accentHex = accent + accentInt = toInt(accent) + log.info('embed accent updated from the site', { accent }) +} + +// Kick off a refresh if the cached value is stale. Never awaited by a reader — +// the current value is returned immediately and the next read sees the new one. +function refreshIfStale() { + if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight + inFlight = fetchAccent() + .catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message })) + .finally(() => { + inFlight = null + }) + return inFlight +} + +module.exports = { + name, + // Getters, not values: consumers already read `brand.accentInt` inline when + // building an embed, so this keeps the accent current with no call-site change. + get accentHex() { + refreshIfStale() + return accentHex + }, + get accentInt() { + refreshIfStale() + return accentInt + }, + // Awaited once at startup so the first embed of a process is already correct. + refreshAccent: () => refreshIfStale() || Promise.resolve(), +} diff --git a/bot/src/server.js b/bot/src/server.js index 582826b..c204c41 100644 --- a/bot/src/server.js +++ b/bot/src/server.js @@ -21,6 +21,11 @@ async function start() { log.info(`internal API listening on http://${HOST}:${PORT}`) }) + // Pick up the site's effective accent before the first embed can be built. + // Best-effort by design: it never rejects, and a site that is not up yet just + // leaves the bot on its BRAND_ACCENT_COLOR default until the next read. + await brand.refreshAccent() + await bootstrap() setupShutdown(server) diff --git a/bot/src/site/siteApiClient.js b/bot/src/site/siteApiClient.js index 0fe8bfe..0c7a524 100644 --- a/bot/src/site/siteApiClient.js +++ b/bot/src/site/siteApiClient.js @@ -31,6 +31,15 @@ async function call(path) { } } +// The site's public settings, including the brand block. Used for the embed +// accent (see brand.js): the admin can theme the site at runtime, and the +// server resolves the effective accent into brand.accent, so this is how the +// bot's embeds track a theme change instead of being stuck on the value +// BRAND_ACCENT_COLOR had when the container started. +function getPublicSettings() { + return call('/settings') +} + function getNewsPost(idOrSlug) { return call(`/posts/news/${encodeURIComponent(idOrSlug)}`) } @@ -39,4 +48,4 @@ function searchWiki(query) { return call(`/wiki?q=${encodeURIComponent(query)}`) } -module.exports = { getNewsPost, searchWiki } +module.exports = { getPublicSettings, getNewsPost, searchWiki } diff --git a/client/index.html b/client/index.html index d92425b..a45f2d3 100644 --- a/client/index.html +++ b/client/index.html @@ -7,7 +7,16 @@ - + +
diff --git a/client/src/App.jsx b/client/src/App.jsx index 3d46f16..2095d3f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -41,6 +41,7 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx' import PageBuilder from './routes/admin/views/PageBuilder.jsx' import WikiAdmin from './routes/admin/views/WikiAdmin.jsx' import HeroEditor from './routes/admin/views/HeroEditor.jsx' +import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' @@ -139,6 +140,17 @@ export default function App() { } /> } /> } /> + {/* Theme editing writes an admin-only settings key; the route sits + behind the same RoleGate as the sidebar entry that reaches it, + and PUT/DELETE /admin/settings is admin-only server-side too. */} + + + + } + /> } /> req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }), + // ----- settings (any authenticated account) ----- + // Nav overrides for the layouts the caller's own role renders, and the theme + // catalog the appearance form is built from. A fifth group, not part of + // /admin, because AdminLayout renders for editors and moderators too — see + // docs/website/THEMING_AND_NAV.md §4.2. + navSettings: () => req('/settings/nav'), + themeOptions: () => req('/settings/theme/options'), + // ----- public ----- publicSettings: () => req('/public/settings'), status: () => req('/public/status'), @@ -280,6 +288,10 @@ export const api = { deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }), getSettings: () => req('/admin/settings'), updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }), + // Reset one setting to its default by deleting the row — the theming/nav + // 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' }), 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/contexts/SiteContext.jsx b/client/src/contexts/SiteContext.jsx index 958556a..71c4a2c 100644 --- a/client/src/contexts/SiteContext.jsx +++ b/client/src/contexts/SiteContext.jsx @@ -1,5 +1,6 @@ -import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' +import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react' import { api } from '../api/client.js' +import { applyThemeTokens } from '../lib/themeVars.js' const SiteContext = createContext(null) @@ -25,11 +26,28 @@ export function SiteProvider({ children }) { const brand = useMemo(() => settings.brand || {}, [settings]) + // Apply the admin's theme. The whole effective token set is resolved + // server-side, so this only writes it and takes back what it wrote before — + // see lib/themeVars.js for why the removal half matters. No theme block means + // the admin never themed this instance, and the shipped :root stands. + const appliedTokens = useRef([]) + useEffect(() => { + appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current) + }, [settings.theme]) + // 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). + // so branding flows to every `var(--accent)` at runtime (no rebuild). This is + // the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR + // server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the + // theme block rather than fighting it. + // + // Deliberately ordered after the theme effect and re-run on any theme change: + // resetting a theme removes --accent from the token map, and this has to be + // the write that lands last or an instance with a custom BRAND_ACCENT_COLOR + // would drop to the stylesheet's default accent until the next reload. useEffect(() => { if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent) - }, [brand.accent]) + }, [brand.accent, settings.theme]) // Memoized so consumers don't re-render on every provider render (brand is a // fresh object each render, which would otherwise churn the context value). diff --git a/client/src/lib/themeVars.js b/client/src/lib/themeVars.js new file mode 100644 index 0000000..97f936f --- /dev/null +++ b/client/src/lib/themeVars.js @@ -0,0 +1,47 @@ +// Apply the server-resolved theme to the document as CSS custom properties. +// +// The effective token set is resolved server-side and arrives on +// `settings.theme` (see server/src/utils/themeResolve.js). The client's only +// job is to write it onto — and, crucially, to take back what it wrote +// last time, which is the part with actual logic and the reason this lives in +// its own testable module. +// +// Why removal matters: an admin who resets the theme, or switches from a preset +// that sets --bg to one that does not, gets a payload that no longer mentions +// that variable. Inline properties are not cleared by writing a smaller object +// over them, so without an explicit removeProperty the old value would stick +// until a reload. That would make "Reset to defaults" look broken. +// +// Everything written here is a value the server validated against a closed set +// (hex color, curated font stack, bounded px length, listed shadow). The client +// deliberately does not re-validate — it would be a second, drifting authority. +// It does refuse anything that is not a `--custom-property`, which is the one +// check that costs nothing and stops a token map from reaching an ordinary CSS +// property. + +const CUSTOM_PROPERTY = /^--[a-zA-Z0-9-_]+$/ + +/** + * @param {CSSStyleDeclaration} style usually document.documentElement.style + * @param {Record|null|undefined} tokens the new theme, or + * null/absent for "no admin theme" — which clears everything previously set + * @param {string[]} [applied] the keys this function wrote last time + * @returns {string[]} the keys now applied, to pass back on the next call + */ +export function applyThemeTokens(style, tokens, applied = []) { + const next = [] + if (tokens && typeof tokens === 'object') { + for (const [name, value] of Object.entries(tokens)) { + if (!CUSTOM_PROPERTY.test(name) || typeof value !== 'string' || value === '') continue + style.setProperty(name, value) + next.push(name) + } + } + // Take back only what we set ourselves. Anything else on the element's inline + // style belongs to someone else (SiteContext's own --accent line, a future + // feature) and is not ours to clear. + for (const name of applied) { + if (!next.includes(name)) style.removeProperty(name) + } + return next +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 200476a..341773b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -38,6 +38,7 @@ const IconBot = () =>

const IconUser = () => const IconShard = () => +const IconPalette = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -74,6 +75,7 @@ const NAV = [ { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, { to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] }, + { to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] }, { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] }, @@ -104,6 +106,7 @@ const TITLES = { '/admin/shard-ops': 'In-Game Ops', '/admin/houses': 'House Registry', '/admin/settings': 'Site Settings', + '/admin/appearance': 'Appearance', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', '/admin/discord-bot': 'Discord Bot', diff --git a/client/src/routes/admin/views/AppearanceAdmin.jsx b/client/src/routes/admin/views/AppearanceAdmin.jsx new file mode 100644 index 0000000..2e9908c --- /dev/null +++ b/client/src/routes/admin/views/AppearanceAdmin.jsx @@ -0,0 +1,354 @@ +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' + +// 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. +// +// Two things shape this form: +// +// • Every control is a closed set. The presets, the font shortlist and the +// shadow depths all come from GET /settings/theme/options, which is derived +// from the same server config the save is validated against — so the form +// can never offer a value the server would reject. Nothing here is free +// text except the color inputs, which are and so are +// hex by construction. +// • Saving means writing a settings row; resetting means DELETING it. Absence +// of the row is what selects the shipped default, so "reset" cannot write a +// copy of the defaults — see §2. + +// Human labels for the eight editable colors and four radii. The field names +// and the CSS variables they drive both come from the server +// (colorFields / radiusFields); this only decorates them, and a field with no +// label here still renders under its raw name rather than vanishing. +const COLOR_LABELS = { + bg: 'Background', + bgDeep: 'Background (deep)', + panelA: 'Panel (top)', + panelB: 'Panel (bottom)', + accent: 'Accent', + accentBright: 'Accent (bright)', + ink: 'Ink / headings', + text: 'Body text', +} +const RADIUS_LABELS = { + radiusPill: 'Pills & buttons', + radiusPanel: 'Flat panels', + radiusCard: 'Cards & panels', + radiusInput: 'Inputs & notes', +} +const FONT_LABELS = { + serif: 'Body serif', + display: 'Display / headings', + sans: 'Interface sans', +} + +// Strip empty groups so a theme the admin cleared back out is stored as a bare +// preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a +// field out to "clear" it — remove it (§6.1). +function compactCustom(custom) { + const out = {} + for (const [group, fields] of Object.entries(custom)) { + const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null)) + if (Object.keys(kept).length) out[group] = kept + } + return Object.keys(out).length ? out : null +} + +export default function AppearanceAdmin() { + const { refresh: refreshSite } = useSite() + const [options, setOptions] = useState(null) + const [preset, setPreset] = useState('runic-gateway') + const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} }) + // Whether a theme_visual row exists at all. Drives the "reset" button and the + // "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) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [saved, setSaved] = useState(false) + + useEffect(() => { + let active = true + Promise.all([api.themeOptions(), api.admin.getSettings()]) + .then(([opts, all]) => { + if (!active) return + setOptions(opts) + // The stored value is a JSON string (settings.value is TEXT). Malformed + // reads as absent, exactly as the server treats it — the form then shows + // the shipped default rather than an error. + let parsed = null + try { + const raw = all.theme_visual + parsed = raw ? JSON.parse(raw) : null + } catch { + parsed = null + } + setStored(Boolean(all.theme_visual)) + if (parsed && typeof parsed === 'object') { + setPreset(parsed.preset || 'runic-gateway') + setCustom({ + colors: parsed.custom?.colors || {}, + fonts: parsed.custom?.fonts || {}, + structure: parsed.custom?.structure || {}, + }) + } + }) + .catch(() => active && setError('Could not load the appearance settings.')) + .finally(() => active && setLoading(false)) + return () => { + active = false + } + }, []) + + // What an unset field currently resolves to: the selected preset's palette, + // or the shipped theme when the preset is Custom (which has no base). Lets a + // color picker open on the value the admin is actually looking at. + const baseTokens = useMemo(() => { + if (!options) return {} + return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens + }, [options, preset]) + + if (loading) return + if (error && !options) return + + const setField = (group, field) => (value) => { + setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } })) + setSaved(false) + } + const clearField = (group, field) => () => { + setCustom((c) => { + const next = { ...c[group] } + delete next[field] + return { ...c, [group]: next } + }) + setSaved(false) + } + + async function save() { + setBusy(true) + setError('') + try { + await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } }) + setStored(true) + setSaved(true) + // Repull the public settings so the surrounding admin UI re-themes itself + // immediately — the admin sees the change they just made. + await refreshSite() + } catch (err) { + setError(err.message || 'Could not save the theme.') + } finally { + setBusy(false) + } + } + + async function resetAll() { + setBusy(true) + setError('') + try { + await api.admin.resetSetting('theme_visual') + setPreset('runic-gateway') + setCustom({ colors: {}, fonts: {}, structure: {} }) + setStored(false) + setSaved(false) + await refreshSite() + } catch (err) { + setError(err.message || 'Could not reset the theme.') + } finally { + setBusy(false) + } + } + + return ( +

+

+ Colors, fonts and corner radius for the public site, this admin panel and the player portal. + {' '} + {stored ? ( + <>This instance has a saved theme. Reset to default deletes it and returns to the shipped look. + ) : ( + <>This instance has never been themed, so it uses the shipped look and its BRAND_* accent. + )} +

+ + {/* ── Preset ─────────────────────────────────────────────── */} +
+ Preset +
+ {options.presets.map((p) => ( + + ))} +
+ + {preset === 'custom' + ? 'Custom starts from the shipped theme — only the fields you set below change.' + : 'A preset sets the whole palette. Anything you set below overrides it, field by field.'} + +
+ + {/* ── Colors ─────────────────────────────────────────────── */} +
+ Colors +
+ {options.colorFields.map(({ name, token }) => { + const set = custom.colors[name] !== undefined + return ( +
+ {/* has no empty state, so an unset field + shows what it currently resolves to rather than black. */} + setField('colors', name)(e.target.value)} + aria-label={COLOR_LABELS[name] || name} + style={{ width: 34, height: 30, padding: 0, border: '1px solid var(--line)', borderRadius: 6, background: 'transparent', cursor: 'pointer' }} + /> + + {COLOR_LABELS[name] || name} + + {set && ( + + )} +
+ ) + })} +
+ + A color you have not set follows the preset. “Live” and “maintenance” status colors are never themed — green has to keep meaning live. + +
+ + {/* ── Fonts ──────────────────────────────────────────────── */} +
+ Fonts +
+ {Object.keys(options.fonts).map((role) => ( + + ))} +
+
+ + {/* ── Structure ──────────────────────────────────────────── */} +
+ Corners & depth +
+ {options.radiusFields.map(({ name, token }) => ( + + ))} +
+ +
+ +
+ + + {saved && Saved.} + {error && {error}} +
+ +

+ The accent reaches the mobile app and the Discord bot too — both theme themselves from this + site’s public branding. +

+
+ ) +} + +const linkBtn = { + border: 'none', + background: 'transparent', + color: 'var(--accent)', + fontSize: '0.72rem', + cursor: 'pointer', + padding: 0, +} diff --git a/client/test/themeVars.test.js b/client/test/themeVars.test.js new file mode 100644 index 0000000..1bd15eb --- /dev/null +++ b/client/test/themeVars.test.js @@ -0,0 +1,100 @@ +// applyThemeTokens — writing the server-resolved theme onto the document, and +// (the part with real logic) taking back exactly what it wrote last time. +// +// Pure module, exercised against a fake CSSStyleDeclaration: node --test has no +// DOM, and the function only ever needs setProperty/removeProperty. +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { applyThemeTokens } from '../src/lib/themeVars.js' + +// Minimal stand-in for element.style, plus a log of the calls so a test can +// assert that a property was *removed* rather than merely absent. +function fakeStyle() { + const props = new Map() + const removed = [] + return { + props, + removed, + setProperty: (name, value) => props.set(name, value), + removeProperty: (name) => { + props.delete(name) + removed.push(name) + }, + get: (name) => props.get(name), + } +} + +test('writes each token and reports the keys it applied', () => { + const style = fakeStyle() + const applied = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' }) + assert.equal(style.get('--accent'), '#c9973f') + assert.equal(style.get('--bg'), '#1a120b') + assert.deepEqual(applied.sort(), ['--accent', '--bg']) +}) + +// The untouched-instance case: no theme block means the stylesheet's :root +// stands and nothing is written at all. +test('no theme writes nothing', () => { + for (const empty of [null, undefined, {}]) { + const style = fakeStyle() + const applied = applyThemeTokens(style, empty) + assert.equal(style.props.size, 0) + assert.deepEqual(applied, []) + } +}) + +test('removes a token that is no longer in the theme', () => { + const style = fakeStyle() + const first = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' }) + const second = applyThemeTokens(style, { '--accent': '#c9973f' }, first) + assert.equal(style.get('--accent'), '#c9973f') + assert.equal(style.get('--bg'), undefined) + assert.deepEqual(style.removed, ['--bg']) + assert.deepEqual(second, ['--accent']) +}) + +// "Reset to defaults" — the case that would look broken without the removal +// half: the payload stops mentioning the variables, and the inline values have +// to come off for :root to show through again. +test('resetting to no theme clears everything previously applied', () => { + const style = fakeStyle() + const first = applyThemeTokens(style, { '--accent': '#c9973f', '--radius-card': '2px' }) + const second = applyThemeTokens(style, null, first) + assert.equal(style.props.size, 0) + assert.deepEqual(style.removed.sort(), ['--accent', '--radius-card']) + assert.deepEqual(second, []) +}) + +// Only ever clears its own keys. SiteContext writes --accent itself from +// brand.accent, and a future feature may write others; those are not ours. +test('never removes a property it did not apply', () => { + const style = fakeStyle() + style.setProperty('--accent', '#ff0000') // someone else's write + applyThemeTokens(style, { '--bg': '#000000' }, []) + assert.equal(style.get('--accent'), '#ff0000') + assert.deepEqual(style.removed, []) +}) + +test('ignores anything that is not a custom property', () => { + const style = fakeStyle() + const applied = applyThemeTokens(style, { background: 'url(http://evil.example/x)', '--bg': '#000000' }) + assert.equal(style.get('background'), undefined) + assert.deepEqual(applied, ['--bg']) +}) + +test('ignores non-string and empty values', () => { + const style = fakeStyle() + const applied = applyThemeTokens(style, { '--a': 4, '--b': null, '--c': '', '--d': '#fff' }) + assert.deepEqual(applied, ['--d']) +}) + +// A stale key list must not survive a call that could not write: the next call +// still has to know what is actually on the element. +test('a token dropped as invalid is removed if it was applied before', () => { + const style = fakeStyle() + const first = applyThemeTokens(style, { '--bg': '#000000' }) + const second = applyThemeTokens(style, { '--bg': '' }, first) + assert.equal(style.get('--bg'), undefined) + assert.deepEqual(second, []) +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index 24c25f0..3304f14 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -2154,6 +2154,15 @@ "noindex", "requireAuth" ] + }, + { + "method": "GET", + "path": "/api/v1/settings/theme/options", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] } ], "internal": [ diff --git a/server/routes.manifest.json b/server/routes.manifest.json index ad7d5d3..f91067f 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -900,6 +900,10 @@ { "method": "GET", "path": "/api/v1/settings/nav" + }, + { + "method": "GET", + "path": "/api/v1/settings/theme/options" } ], "internal": [ diff --git a/server/src/config/themePresets.js b/server/src/config/themePresets.js new file mode 100644 index 0000000..1b989b0 --- /dev/null +++ b/server/src/config/themePresets.js @@ -0,0 +1,215 @@ +// ── Theme presets & the closed sets an admin may choose from ─────────────── +// +// The single authority for admin-configurable theming (docs/website/THEMING_AND_NAV.md +// §5-§6). Everything an admin can pick is enumerated here; nothing is free text. +// +// Why the server owns this rather than theme.css: +// The effective token set is resolved server-side and returned by +// settings.getPublic() as `theme`, which the SPA writes onto the document as +// CSS custom properties. That keeps ONE authority for the override merge +// (:root ← preset ← custom), lets brand.accent — a cross-repo contract the +// Android app themes itself from — report the same accent the website paints, +// and avoids the precedence trap of `[data-theme]` blocks losing to the inline +// `--accent` SiteContext already sets on . +// +// theme.css's `:root` remains the default and is NOT duplicated here beyond +// the runic-gateway preset. An instance with no `theme_visual` row gets no +// `theme` block at all and renders from :root exactly as it does today. +// +// Security note: these values end up as CSS custom property values. Every one is +// picked from a closed set (a preset id, a shortlist stack, a bounded px length, +// a hex color) — see utils/themeResolve.js, which both the write path and the +// read path validate through. + +// The three color tokens that are semantic rather than decorative. They mean +// "live" and "maintenance" and stay fixed across every preset — green is not a +// brand choice. Deliberately absent from every preset block below. +const FIXED_TOKENS = ['--mode-live', '--mode-maint'] + +// Full palettes. A preset must carry EVERY color token, not just the eight the +// admin form exposes: a partial palette leaves e.g. --line and --blue at their +// dark-blue :root values, which reads as broken on a warm background. +// +// --panel-grad is deliberately absent: it is derived (`linear-gradient(180deg, +// var(--panel-a), var(--panel-b))`) and must stay derived, or a future light +// preset silently inherits a dark gradient. +const PRESETS = { + // Today's :root, verbatim. Declared as a preset so that switching back to it + // after trying another is the same code path as any other choice. + 'runic-gateway': { + label: 'Runic Gateway', + tokens: { + '--bg': '#0e1318', + '--bg-deep': '#0b0f14', + '--panel-a': '#192231', + '--panel-b': '#141a21', + '--panel-flat': '#11161d', + '--line': '#2a3544', + '--line-soft': '#1d2733', + '--accent': '#7f99bd', + '--accent-bright': '#cdd9e8', + '--ink': '#eef3f8', + '--head': '#e6edf6', + '--text': '#c4cdd8', + '--muted': '#aeb8c4', + '--dim': '#6f7d8e', + '--blue': '#13243c', + '--radius-pill': '999px', + '--radius-panel': '12px', + '--radius-card': '10px', + '--radius-input': '8px', + '--shadow-card': '0 14px 34px rgba(0, 0, 0, 0.3)', + '--serif': 'Georgia, "Times New Roman", serif', + '--display': 'Cinzel, Georgia, serif', + '--sans': '"Helvetica Neue", Arial, sans-serif', + }, + }, + // Flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. + modern: { + label: 'Modern', + tokens: { + '--bg': '#101114', + '--bg-deep': '#0a0a0c', + '--panel-a': '#1c1d22', + '--panel-b': '#17181c', + '--panel-flat': '#141519', + '--line': '#2b2d34', + '--line-soft': '#212329', + '--accent': '#4f8ef7', + '--accent-bright': '#a8c8ff', + '--ink': '#f2f3f5', + '--head': '#f7f8fa', + '--text': '#b8bcc4', + '--muted': '#a9aeb8', + '--dim': '#71767f', + '--blue': '#1b2c47', + '--radius-pill': '8px', + '--radius-panel': '8px', + '--radius-card': '6px', + '--radius-input': '6px', + '--shadow-card': '0 8px 20px rgba(0, 0, 0, 0.25)', + '--serif': 'Inter, Arial, sans-serif', + '--display': "'Work Sans', Arial, sans-serif", + '--sans': 'Inter, Arial, sans-serif', + }, + }, + // Warmer, higher contrast, carved corners; leans into UO harder. + fantasy: { + label: 'Fantasy', + tokens: { + '--bg': '#1a120b', + '--bg-deep': '#120c07', + '--panel-a': '#2c1f14', + '--panel-b': '#241a10', + '--panel-flat': '#1f160d', + '--line': '#4a3721', + '--line-soft': '#33251a', + '--accent': '#c9973f', + '--accent-bright': '#e8c374', + '--ink': '#f3e8d4', + '--head': '#f7efe0', + '--text': '#d3bfa0', + '--muted': '#bfa985', + '--dim': '#8a7454', + '--blue': '#382613', + '--radius-pill': '4px', + '--radius-panel': '3px', + '--radius-card': '2px', + '--radius-input': '2px', + '--shadow-card': '0 16px 38px rgba(0, 0, 0, 0.45)', + '--serif': "'EB Garamond', Georgia, serif", + '--display': 'Cinzel, Georgia, serif', + '--sans': "'EB Garamond', Georgia, serif", + }, + }, +} + +// 'custom' is a valid stored preset meaning "no preset base" — :root plus +// whatever custom fields are set. It has no palette of its own. +const CUSTOM_PRESET = 'custom' +const PRESET_IDS = [...Object.keys(PRESETS), CUSTOM_PRESET] + +// The colors the admin form exposes, mapped to their CSS token. Deliberately +// the eight of §6.1 rather than all fifteen: the rest are supporting shades a +// preset sets coherently but that are not worth (or safe to) hand-picking. +const COLOR_FIELDS = { + bg: '--bg', + bgDeep: '--bg-deep', + panelA: '--panel-a', + panelB: '--panel-b', + accent: '--accent', + accentBright: '--accent-bright', + ink: '--ink', + text: '--text', +} + +const RADIUS_FIELDS = { + radiusPill: '--radius-pill', + radiusPanel: '--radius-panel', + radiusCard: '--radius-card', + radiusInput: '--radius-input', +} + +const FONT_FIELDS = { + serif: '--serif', + display: '--display', + sans: '--sans', +} + +// The curated Google Fonts shortlist (§5.1). The dropdown's VALUE is the full +// stack exactly as applied, so no string is ever built from admin input and no +// Google Fonts URL is ever assembled at runtime — the combined css2? request in +// client/index.html is static and covers all eight web families. +// +// One addition to §5.1's twelve: Georgia in the serif list. The shortlist as +// drafted gave the sans role a "current default" option (Arial, byte-identical +// to today's --sans) but left the serif role with no way back to today's +// `Georgia, "Times New Roman", serif` short of resetting the whole theme. It +// pulls in no web family, so §5.2's URL is unchanged. +const FONT_OPTIONS = { + serif: [ + { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — strongest fantasy/historic' }, + { value: 'Merriweather, Georgia, serif', label: 'Merriweather — excellent readability' }, + { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant/editorial' }, + { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — old-world (no bold weight)' }, + { value: 'Georgia, "Times New Roman", serif', label: 'Georgia — the shipped default' }, + ], + display: [ + { value: 'Cinzel, Georgia, serif', label: 'Cinzel — current Runic Gateway identity' }, + { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant alternative' }, + { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — softer/classic' }, + { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — very strong fantasy (no bold weight)' }, + ], + sans: [ + { value: 'Inter, Arial, sans-serif', label: 'Inter — default modern UI choice' }, + { value: "'Work Sans', Arial, sans-serif", label: 'Work Sans — slightly more character' }, + { value: "'Source Sans 3', Arial, sans-serif", label: 'Source Sans 3 — extremely readable' }, + { value: '"Helvetica Neue", Arial, sans-serif', label: 'Arial — no webfont; the shipped default' }, + ], +} + +// Shadow depth, as a closed set for the same reason fonts are: the stored value +// is applied verbatim as --shadow-card. +const SHADOW_OPTIONS = [ + { value: 'none', label: 'None — flat' }, + { value: '0 8px 20px rgba(0, 0, 0, 0.25)', label: 'Soft' }, + { value: '0 14px 34px rgba(0, 0, 0, 0.3)', label: 'Default' }, + { value: '0 18px 44px rgba(0, 0, 0, 0.45)', label: 'Deep' }, +] + +// Corner radius is a number, not a shortlist, so it is bounded instead: an +// integer count of px from 0 to 999 (999 being the pill). +const RADIUS_MAX_PX = 999 + +module.exports = { + PRESETS, + PRESET_IDS, + CUSTOM_PRESET, + FIXED_TOKENS, + COLOR_FIELDS, + RADIUS_FIELDS, + FONT_FIELDS, + FONT_OPTIONS, + SHADOW_OPTIONS, + RADIUS_MAX_PX, +} diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index a2bd505..db708c5 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -1,5 +1,7 @@ const settingsDb = require('./settings.db') const brand = require('../../config/brand') +const { parseJsonSetting } = require('../../utils/settingsJson') +const { resolveThemeTokens } = require('../../utils/themeResolve') // Keys safe to expose on the public site. const PUBLIC_KEYS = [ @@ -147,9 +149,28 @@ async function getPublic() { // the final say when the call is made). Lets the portal show/hide the form. const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled' out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode) - // Instance branding (BRAND_* env defaults). The two admin-editable settings — - // site title and contact email — override the env value when set, so existing - // installs keep their DB-configured name; everything else comes from env. + // The effective CSS custom properties for the admin's theme, or absent when + // no theme_visual row exists (or nothing in it was usable). The SPA writes + // these onto ; absence means it writes nothing and theme.css's :root + // stands, which is what keeps an untouched instance byte-for-byte as today. + // Resolution — :root ← preset ← custom — happens here rather than in CSS so + // there is one authority and brand.accent below can report the same value the + // 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) || {} + // 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 + // DB-configured name; everything else comes from env. + // + // brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole + // Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot + // colors its embeds from it. Resolving the effective accent here is what lets + // both track admin theming with no client change. out.brand = { name: out.site_title || brand.name, shortName: brand.shortName, @@ -157,10 +178,10 @@ async function getPublic() { description: brand.description, contactEmail: out.contact_email || brand.contactEmail, url: brand.url, - accent: brand.accent, - logo: brand.logo, - hero: brand.hero, - favicon: brand.favicon, + accent: theme?.['--accent'] || brand.accent, + logo: brandAssets.logo || brand.logo, + hero: brandAssets.hero || brand.hero, + favicon: brandAssets.favicon || brand.favicon, } // Push-notification relay (M7). The client-facing ntfy base URL the app's // embedded distributor registers its device topic against; null when push is diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index d80ce14..e53c1e3 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -9,6 +9,8 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model') const newsGump = require('../../../utils/newsGump') const pushDispatch = require('../../../utils/pushDispatch') const { cleanBody } = require('../../../utils/sanitizeHtml') +const { parseJsonSetting } = require('../../../utils/settingsJson') +const { validateThemeVisual } = require('../../../utils/themeResolve') const log = require('../../../utils/logger')('admin') @@ -529,6 +531,22 @@ async function updateSettings(req, res) { if (typeof updates.homepage_teaser === 'string') { updates.homepage_teaser = cleanBody(updates.homepage_teaser) } + // theme_visual is JSON whose values become CSS custom properties, so every + // one has to come from the closed sets in config/themePresets.js. The read + // path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently + // storing a value that will never apply is a bad admin experience — reject it + // with the offending field named instead. Accepts an object or the stringified + // form, and stores it stringified either way, since settings.value is TEXT. + if ('theme_visual' in updates) { + const raw = updates.theme_visual + const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw + if (typeof raw === 'string' && parsed === null) { + return res.status(400).json({ message: 'theme_visual must be a JSON object' }) + } + const check = validateThemeVisual(parsed) + if (!check.ok) return res.status(400).json({ message: check.message }) + updates.theme_visual = JSON.stringify(parsed) + } try { await settings.setMany(updates, req.user.id) await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } }) diff --git a/server/src/router/v1/settings/index.js b/server/src/router/v1/settings/index.js index 2bf8c72..3fe6989 100644 --- a/server/src/router/v1/settings/index.js +++ b/server/src/router/v1/settings/index.js @@ -23,11 +23,13 @@ const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const navRouter = require('./nav.router') +const themeRouter = require('./theme.router') const settingsRouter = express.Router() settingsRouter.use(noindex, requireAuth) settingsRouter.use('/nav', navRouter) +settingsRouter.use('/theme', themeRouter) module.exports = settingsRouter diff --git a/server/src/router/v1/settings/nav.controller.js b/server/src/router/v1/settings/nav.controller.js index 249ad23..8d0400d 100644 --- a/server/src/router/v1/settings/nav.controller.js +++ b/server/src/router/v1/settings/nav.controller.js @@ -1,5 +1,10 @@ const settings = require('../../../model/settings/settings.model') -const log = require('../../../utils/logger') + +// The logger module exports a FACTORY — calling it is what yields {error, warn, +// info, debug}. Using the factory directly makes `log.error` undefined, which +// would turn a DB fault into a TypeError thrown inside the catch (no response +// sent, request left hanging) instead of a 500. +const log = require('../../../utils/logger')('settings') // The nav overrides for the two authenticated layouts. Values are the raw stored // JSON strings (settings.value is TEXT) or null; the caller parses them with the diff --git a/server/src/router/v1/settings/theme.controller.js b/server/src/router/v1/settings/theme.controller.js new file mode 100644 index 0000000..11f92c9 --- /dev/null +++ b/server/src/router/v1/settings/theme.controller.js @@ -0,0 +1,17 @@ +const { themeOptions } = require('../../../utils/themeResolve') + +// The theme catalog the admin appearance form builds its controls from: the +// presets and their swatches, the curated font shortlist, the shadow depths, +// and which color and radius fields are editable. +// +// Served rather than duplicated in client code so the options the form OFFERS +// can never drift from the ones validateThemeVisual() ACCEPTS — a drift shows +// up as an admin picking a font and the save 400ing for no visible reason. +// +// Static: derived from config/themePresets.js with no DB read, so there is +// nothing here to fail and no error branch to write. +function getThemeOptions(req, res) { + return res.json(themeOptions()) +} + +module.exports = { getThemeOptions } diff --git a/server/src/router/v1/settings/theme.router.js b/server/src/router/v1/settings/theme.router.js new file mode 100644 index 0000000..c0944a0 --- /dev/null +++ b/server/src/router/v1/settings/theme.router.js @@ -0,0 +1,26 @@ +// Settings · Theme — the closed sets the admin appearance form is built from. +// +// Mounted at /api/v1/settings/theme by settings/index.js, which already applied +// `noindex, requireAuth`. No role gate is added here for the same reason the +// group has none: it is a static catalog of presets and font names, not +// configuration and not anything about the caller. The route that WRITES a +// theme is PUT /api/v1/admin/settings, which is admin-only. + +const express = require('express') + +const ctrl = require('./theme.controller') + +const themeRouter = express.Router() + +themeRouter.get( + '/options', + // #swagger.tags = ['Settings'] + // #swagger.summary = 'Theme presets and the curated option lists' + // #swagger.description = 'The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Theme option catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/ThemeOptions" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.getThemeOptions, +) + +module.exports = themeRouter diff --git a/server/src/utils/themeResolve.js b/server/src/utils/themeResolve.js new file mode 100644 index 0000000..c1b556b --- /dev/null +++ b/server/src/utils/themeResolve.js @@ -0,0 +1,202 @@ +// ── theme_visual: validate on write, resolve on read ─────────────────────── +// +// Two jobs, one closed set of rules (config/themePresets.js): +// +// validateThemeVisual() the WRITE path. PUT /admin/settings rejects a bad +// theme_visual with a 400 rather than storing it, so an +// admin gets told why instead of watching a save appear +// to succeed and do nothing. +// resolveThemeTokens() the READ path. Turns the stored value into the CSS +// custom properties settings.getPublic() ships as +// `theme`. Fail-safe, per §4.4: anything unrecognized +// is dropped field-by-field and the surface falls back +// to theme.css's :root — never an error, never a +// half-applied palette. +// +// The write path is the strict one and the read path is the forgiving one on +// purpose. Strict-on-write gives feedback; forgiving-on-read means a row +// hand-edited in the DB, or written by an older version of this code, degrades +// to the shipped default instead of rendering a broken site. +// +// See docs/website/THEMING_AND_NAV.md §5-§6. + +const { + PRESETS, + PRESET_IDS, + CUSTOM_PRESET, + COLOR_FIELDS, + RADIUS_FIELDS, + FONT_FIELDS, + FONT_OPTIONS, + SHADOW_OPTIONS, + RADIUS_MAX_PX, +} = require('../config/themePresets') +const { parseJsonSetting } = require('./settingsJson') + +const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ +const PX_LENGTH = /^(\d{1,3})px$/ + +const SHADOW_VALUES = SHADOW_OPTIONS.map((o) => o.value) +const FONT_VALUES = Object.fromEntries( + Object.keys(FONT_FIELDS).map((role) => [role, FONT_OPTIONS[role].map((o) => o.value)]), +) + +function isPlainObject(v) { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +function isColor(v) { + return typeof v === 'string' && HEX_COLOR.test(v) +} + +// A bounded px length. `0` on its own is not accepted — a radius is always +// written with a unit here, which keeps the stored shape uniform. +function isRadius(v) { + if (typeof v !== 'string') return false + const m = PX_LENGTH.exec(v) + return !!m && Number(m[1]) <= RADIUS_MAX_PX +} + +function isShadow(v) { + return typeof v === 'string' && SHADOW_VALUES.includes(v) +} + +function isFont(role, v) { + return typeof v === 'string' && (FONT_VALUES[role] || []).includes(v) +} + +// Per-field check for one custom group. Returns the list of offending field +// names, so the write path can say which field was wrong. +function checkGroup(group, fields, check) { + const bad = [] + for (const [field, value] of Object.entries(group)) { + if (!(field in fields)) { + bad.push(field) + } else if (!check(field, value)) { + bad.push(field) + } + } + return bad +} + +/** + * Strict shape check for the write path. + * + * @param {unknown} value the parsed theme_visual object + * @returns {{ ok: true } | { ok: false, message: string }} + */ +function validateThemeVisual(value) { + if (!isPlainObject(value)) return { ok: false, message: 'theme_visual must be a JSON object' } + + const keys = Object.keys(value).filter((k) => k !== 'preset' && k !== 'custom') + if (keys.length) return { ok: false, message: `theme_visual: unknown field(s) ${keys.join(', ')}` } + + if (!PRESET_IDS.includes(value.preset)) { + return { ok: false, message: `theme_visual.preset must be one of ${PRESET_IDS.join(', ')}` } + } + + // `custom` is optional and may be explicitly null ("preset only"). + const custom = value.custom + if (custom === undefined || custom === null) return { ok: true } + if (!isPlainObject(custom)) return { ok: false, message: 'theme_visual.custom must be an object or null' } + + const groups = Object.keys(custom).filter((g) => !['colors', 'structure', 'fonts'].includes(g)) + if (groups.length) return { ok: false, message: `theme_visual.custom: unknown group(s) ${groups.join(', ')}` } + + for (const [group, spec] of [ + ['colors', { fields: COLOR_FIELDS, check: (_f, v) => isColor(v) }], + ['fonts', { fields: FONT_FIELDS, check: (f, v) => isFont(f, v) }], + [ + 'structure', + { + fields: { ...RADIUS_FIELDS, shadowDepth: '--shadow-card' }, + check: (f, v) => (f === 'shadowDepth' ? isShadow(v) : isRadius(v)), + }, + ], + ]) { + const supplied = custom[group] + if (supplied === undefined || supplied === null) continue + if (!isPlainObject(supplied)) return { ok: false, message: `theme_visual.custom.${group} must be an object` } + const bad = checkGroup(supplied, spec.fields, spec.check) + if (bad.length) return { ok: false, message: `theme_visual.custom.${group}: invalid value for ${bad.join(', ')}` } + } + + return { ok: true } +} + +// Copy the fields of one custom group that pass their check onto the token map. +// Field-by-field: a bad accent does not discard a good bg beside it. +function applyGroup(tokens, group, fields, check) { + if (!isPlainObject(group)) return + for (const [field, token] of Object.entries(fields)) { + const value = group[field] + if (value !== undefined && check(field, value)) tokens[token] = value + } +} + +/** + * The effective CSS custom properties for a stored theme_visual value. + * + * Layered :root ← preset ← custom, per field. `null` means "no row, or nothing + * usable in it" — the caller omits the block entirely and the client applies + * nothing, which is what makes an untouched instance render byte-for-byte as + * today. + * + * @param {string|object|null|undefined} stored the raw settings value (TEXT) or + * an already-parsed object + * @returns {Record|null} + */ +function resolveThemeTokens(stored) { + const parsed = typeof stored === 'string' ? parseJsonSetting(stored) : isPlainObject(stored) ? stored : null + if (!parsed) return null + + // An unrecognized preset id falls back to no base rather than to a guess: the + // admin's custom fields still apply on top of :root. + const base = PRESETS[parsed.preset] + const tokens = base ? { ...base.tokens } : {} + + const custom = parsed.custom + if (isPlainObject(custom)) { + applyGroup(tokens, custom.colors, COLOR_FIELDS, (_f, v) => isColor(v)) + applyGroup(tokens, custom.fonts, FONT_FIELDS, (f, v) => isFont(f, v)) + applyGroup(tokens, custom.structure, RADIUS_FIELDS, (_f, v) => isRadius(v)) + applyGroup(tokens, custom.structure, { shadowDepth: '--shadow-card' }, (_f, v) => isShadow(v)) + } + + // A row that parsed but yielded nothing usable (e.g. `{"preset":"custom"}` + // with no custom fields) is the same as no row at all to every consumer. + return Object.keys(tokens).length ? tokens : null +} + +/** + * The catalog the admin UI builds its controls from. Served rather than + * duplicated client-side so the options offered can never drift from the + * options validateThemeVisual() accepts. + */ +function themeOptions() { + return { + // Full token maps, not just a swatch: the form shows each control's + // *effective* default for the selected preset, so an admin opening the + // accent picker on Fantasy sees Fantasy's gold rather than a hardcoded + // client-side copy of the shipped palette. `custom` has no map — it means + // "no preset base", and the form falls back to the shipped theme, which is + // the runic-gateway map. + presets: [ + ...Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label, tokens: p.tokens })), + { id: CUSTOM_PRESET, label: 'Custom', tokens: null }, + ], + // Each editable field paired with the CSS variable it drives, so the form + // can look its current value up in the preset map above without knowing the + // naming convention that relates the two. + colorFields: Object.entries(COLOR_FIELDS).map(([name, token]) => ({ name, token })), + radiusFields: Object.entries(RADIUS_FIELDS).map(([name, token]) => ({ name, token })), + fonts: FONT_OPTIONS, + shadows: SHADOW_OPTIONS, + radiusMaxPx: RADIUS_MAX_PX, + // The shipped default, i.e. what theme.css's :root already declares. What + // an unset field actually resolves to when no preset is selected. + shippedTokens: PRESETS['runic-gateway'].tokens, + } +} + +module.exports = { validateThemeVisual, resolveThemeTokens, themeOptions } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index dd221ff..7f862d8 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -12772,6 +12772,51 @@ } ] } + }, + "/api/v1/settings/theme/options": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Theme presets and the curated option lists", + "description": "The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.", + "responses": { + "200": { + "description": "Theme option catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThemeOptions" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -17720,7 +17765,7 @@ }, "description": { "type": "string", - "example": "Seed/accent color (hex) for theming." + "example": "Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change." } } }, @@ -17737,7 +17782,7 @@ }, "description": { "type": "string", - "example": "Logo URL or site-relative path; empty = no logo." + "example": "Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO." } } }, @@ -17754,7 +17799,7 @@ }, "description": { "type": "string", - "example": "Hero image URL or site-relative path." + "example": "Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO." } } }, @@ -17771,7 +17816,7 @@ }, "description": { "type": "string", - "example": "Favicon URL or site-relative path." + "example": "Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON." } } } @@ -17880,6 +17925,49 @@ "brand": { "$ref": "#/components/schemas/Brand" }, + "theme": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow)." + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "object", + "properties": { + "--accent": { + "type": "string", + "example": "#c9973f" + }, + "--bg": { + "type": "string", + "example": "#1a120b" + }, + "--radius-card": { + "type": "string", + "example": "2px" + } + } + } + } + }, "push": { "type": "object", "properties": { @@ -17972,6 +18060,344 @@ } } }, + "ThemeOptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database." + }, + "properties": { + "type": "object", + "properties": { + "presets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means \"no preset base — the shipped theme plus whatever custom fields are set\"." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "fantasy" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Fantasy" + } + } + }, + "tokens": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "object", + "properties": { + "--bg": { + "type": "string", + "example": "#1a120b" + }, + "--accent": { + "type": "string", + "example": "#c9973f" + } + } + } + } + } + } + } + } + } + } + }, + "colorFields": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Editable color fields, each paired with the CSS variable it drives." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "accent" + } + } + }, + "token": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "--accent" + } + } + } + } + } + } + } + } + }, + "radiusFields": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "radiusCard" + } + } + }, + "token": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "--radius-card" + } + } + } + } + } + } + } + } + }, + "shippedTokens": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "What the stylesheet declares by default — the values an unset field resolves to when no preset is selected." + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "fonts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Curated Google Fonts shortlist per role. Each option's `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input." + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + } + } + }, + "shadows": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + }, + "radiusMaxPx": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 999 + } + } + } + } + } + } + }, "DeletedId": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 450e979..7395c79 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -748,10 +748,15 @@ const doc = { description: { type: 'string' }, contactEmail: { type: 'string', example: '' }, url: { type: 'string', example: '' }, - accent: { type: 'string', example: '#7f99bd', description: 'Seed/accent color (hex) for theming.' }, - logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo.' }, - hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path.' }, - favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path.' }, + accent: { + type: 'string', + example: '#7f99bd', + description: + 'Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change.', + }, + logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO.' }, + hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO.' }, + favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON.' }, }, }, PublicSettings: { @@ -768,6 +773,14 @@ const doc = { }, gameAccountSignup: { type: 'boolean', example: false }, brand: { $ref: '#/components/schemas/Brand' }, + theme: { + type: 'object', + nullable: true, + description: + 'The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow).', + additionalProperties: { type: 'string' }, + example: { '--accent': '#c9973f', '--bg': '#1a120b', '--radius-card': '2px' }, + }, push: { type: 'object', description: @@ -792,6 +805,70 @@ const doc = { nav_player: { type: 'string', nullable: true, example: null }, }, }, + ThemeOptions: { + type: 'object', + description: + 'The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database.', + properties: { + presets: { + type: 'array', + description: + 'Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means "no preset base — the shipped theme plus whatever custom fields are set".', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'fantasy' }, + label: { type: 'string', example: 'Fantasy' }, + tokens: { + type: 'object', + nullable: true, + additionalProperties: { type: 'string' }, + example: { '--bg': '#1a120b', '--accent': '#c9973f' }, + }, + }, + }, + }, + colorFields: { + type: 'array', + description: 'Editable color fields, each paired with the CSS variable it drives.', + items: { + type: 'object', + properties: { name: { type: 'string', example: 'accent' }, token: { type: 'string', example: '--accent' } }, + }, + }, + radiusFields: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string', example: 'radiusCard' }, token: { type: 'string', example: '--radius-card' } }, + }, + }, + shippedTokens: { + type: 'object', + description: 'What the stylesheet declares by default — the values an unset field resolves to when no preset is selected.', + additionalProperties: { type: 'string' }, + }, + fonts: { + type: 'object', + description: 'Curated Google Fonts shortlist per role. Each option\'s `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input.', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { value: { type: 'string' }, label: { type: 'string' } }, + }, + }, + }, + shadows: { + type: 'array', + items: { + type: 'object', + properties: { value: { type: 'string' }, label: { type: 'string' } }, + }, + }, + radiusMaxPx: { type: 'integer', example: 999 }, + }, + }, // Delete/mutation acknowledgements — each echoes the affected resource key // or a boolean flag rather than a { message } string. DeletedId: { diff --git a/server/test/publicBrand.test.js b/server/test/publicBrand.test.js index 1e36426..749d3bf 100644 --- a/server/test/publicBrand.test.js +++ b/server/test/publicBrand.test.js @@ -56,6 +56,79 @@ test('admin site_title / contact_email override the brand defaults', async () => assert.equal(pub.brand.accent, brand.accent) // colors still from config }) +// ── Effective theming (THEMING_AND_NAV.md §4.5) ─────────────────────── +// +// brand.accent and the asset fields are a cross-repo contract: the Android app +// themes its whole Material palette from brand.accent and the Discord bot +// colors its embeds from it. Resolving the EFFECTIVE value here is what lets +// both track admin theming with no client change — so these tests are really +// about the app and the bot, not about the website. + +test('no theme row leaves the brand block exactly as env defines it', async () => { + // Explicitly re-asserted next to the theming cases: this is the acceptance + // criterion the whole feature rests on, and it is the assertion a future + // change to the resolver would break first. + const pub = await settings.getPublic() + assert.equal(pub.theme, undefined, 'no theme block at all when untouched') + assert.equal(pub.brand.accent, brand.accent) + assert.equal(pub.brand.logo, brand.logo) + assert.equal(pub.brand.hero, brand.hero) + assert.equal(pub.brand.favicon, brand.favicon) +}) + +test('a theme preset overrides brand.accent and ships the token block', async () => { + settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy' }) }] + const pub = await settings.getPublic() + assert.equal(pub.brand.accent, '#c9973f', 'the app sees the themed accent, not BRAND_ACCENT_COLOR') + assert.equal(pub.theme['--accent'], '#c9973f') + assert.equal(pub.theme['--bg'], '#1a120b') + // Assets are a different key and must not move with the theme. + assert.equal(pub.brand.logo, brand.logo) + assert.equal(pub.brand.hero, brand.hero) +}) + +test('a custom accent beats the preset accent in brand.accent', async () => { + settingsDb.getAll = async () => [ + { key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#123456' } } }) }, + ] + const pub = await settings.getPublic() + assert.equal(pub.brand.accent, '#123456') + assert.equal(pub.theme['--accent'], '#123456') +}) + +test('a malformed theme row reads as absent, not as an error', async () => { + for (const value of ['{oops', '"x"', '{"preset":"parchment"}']) { + settingsDb.getAll = async () => [{ key: 'theme_visual', value }] + const pub = await settings.getPublic() + assert.equal(pub.theme, undefined, value) + assert.equal(pub.brand.accent, brand.accent, value) + } +}) + +test('brand_assets overrides one asset without disturbing the others', async () => { + settingsDb.getAll = async () => [ + { key: 'brand_assets', value: JSON.stringify({ favicon: '/uploads/1234-abcd.png' }) }, + ] + const pub = await settings.getPublic() + assert.equal(pub.brand.favicon, '/uploads/1234-abcd.png') + assert.equal(pub.brand.logo, brand.logo, 'logo still from env') + assert.equal(pub.brand.hero, brand.hero, 'hero still from env') +}) + +test('a malformed brand_assets row falls back to env for every asset', async () => { + settingsDb.getAll = async () => [{ key: 'brand_assets', value: 'not json' }] + const pub = await settings.getPublic() + assert.equal(pub.brand.logo, brand.logo) + assert.equal(pub.brand.hero, brand.hero) + assert.equal(pub.brand.favicon, brand.favicon) +}) + +test('the Discord-only integer accent is still never exposed, themed or not', async () => { + settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'modern' }) }] + const pub = await settings.getPublic() + assert.equal(pub.brand.accentInt, undefined) +}) + // The push relay block the app's embedded distributor discovers its ntfy base // URL from (M7 Part 2). Null when nothing is configured; NTFY_PUBLIC_URL wins, // else the first NTFY_ALLOWED_ORIGINS entry; NTFY_BASE_URL is never surfaced. diff --git a/server/test/settingsTheming.test.js b/server/test/settingsTheming.test.js index b8db7eb..462a2a3 100644 --- a/server/test/settingsTheming.test.js +++ b/server/test/settingsTheming.test.js @@ -213,6 +213,95 @@ test('theme_visual / brand_assets / nav_public are public once set; nav_admin / assert.equal(pub.nav_player, undefined) }) +// ── PUT /admin/settings — theme_visual is validated on the way in ────────── +// +// The read path drops anything invalid anyway, so this is about feedback, not +// safety: an admin whose save appears to succeed and then does nothing has no +// way to tell what was wrong. + +test('a valid theme_visual is stored stringified', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + const written = {} + settingsDb.set = async (key, value) => { + written[key] = value + } + settingsDb.getAll = async () => [] + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + const theme = { preset: 'fantasy', custom: { colors: { accent: '#123456' } } } + const res = await fetch(`${app.url}/api/v1/admin/settings`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ theme_visual: theme }), + }) + assert.equal(res.status, 200) + // settings.value is TEXT — an object body must reach the store stringified. + assert.equal(written.theme_visual, JSON.stringify(theme)) + } finally { + settingsDb.set = originals.set + await app.close() + } +}) + +test('an invalid theme_visual is rejected and nothing is written', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.set = () => assert.fail('an invalid theme must not be stored') + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + const bad = [ + { preset: 'parchment' }, + { preset: 'custom', custom: { colors: { accent: 'red' } } }, + { preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS' } } }, + { preset: 'custom', custom: { structure: { radiusCard: '4em' } } }, + { preset: 'custom', custom: { spacing: { unit: '8px' } } }, + 'not json', + ] + for (const theme_visual of bad) { + const res = await fetch(`${app.url}/api/v1/admin/settings`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ theme_visual }), + }) + assert.equal(res.status, 400, JSON.stringify(theme_visual)) + const body = await res.json() + assert.match(body.message, /theme_visual/) + } + } finally { + settingsDb.set = originals.set + await app.close() + } +}) + +// ── GET /settings/theme-options — the catalog the admin form is built from ── + +test('GET /settings/theme/options serves the catalog to an authenticated caller', async () => { + signInAs({ id: 7, username: 'u', role: 'admin', status: 'active' }) + const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/settings/theme/options`) + assert.equal(res.status, 200) + const body = await res.json() + assert.ok(Array.isArray(body.presets) && body.presets.length === 4, 'three presets plus Custom') + assert.ok(body.fonts.serif.length && body.fonts.display.length && body.fonts.sans.length) + assert.ok(body.shadows.length) + assert.ok(body.colorFields.some((f) => f.name === 'accent' && f.token === '--accent')) + assert.equal(body.shippedTokens['--accent'], '#7f99bd') + } finally { + await app.close() + } +}) + +test('GET /settings/theme/options rejects an anonymous caller', async () => { + sessionService.validateSession = () => null + const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/settings/theme/options`) + assert.equal(res.status, 401) + } finally { + await app.close() + } +}) + // ── parseJsonSetting: malformed reads as absent, never as an error ───────── test('parseJsonSetting returns null for absent, empty and malformed values', () => { diff --git a/server/test/themeResolve.test.js b/server/test/themeResolve.test.js new file mode 100644 index 0000000..9acba1c --- /dev/null +++ b/server/test/themeResolve.test.js @@ -0,0 +1,262 @@ +// theme_visual — the strict write path and the fail-safe read path. +// +// The two halves are deliberately asymmetric (see utils/themeResolve.js): a +// write is rejected with the offending field named, while a read drops bad +// fields one at a time and falls back to the shipped :root. These tests lock +// that asymmetry, because it is the thing most likely to get "tidied" into a +// single shared check later. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { validateThemeVisual, resolveThemeTokens, themeOptions } = require('../src/utils/themeResolve') +const { PRESETS, FONT_OPTIONS, SHADOW_OPTIONS } = require('../src/config/themePresets') + +// ── validateThemeVisual (write path) ────────────────────────────────── +test('accepts a bare preset choice', () => { + for (const preset of ['runic-gateway', 'modern', 'fantasy', 'custom']) { + assert.equal(validateThemeVisual({ preset }).ok, true, preset) + } + assert.equal(validateThemeVisual({ preset: 'fantasy', custom: null }).ok, true) +}) + +test('rejects an unknown preset', () => { + const res = validateThemeVisual({ preset: 'parchment' }) + assert.equal(res.ok, false) + assert.match(res.message, /preset must be one of/) +}) + +test('rejects a non-object, and unknown top-level fields', () => { + for (const bad of [null, 4, 'fantasy', []]) { + assert.equal(validateThemeVisual(bad).ok, false) + } + const res = validateThemeVisual({ preset: 'modern', mode: 'light' }) + assert.equal(res.ok, false) + assert.match(res.message, /unknown field\(s\) mode/) +}) + +test('accepts custom colors, fonts and structure from the closed sets', () => { + const res = validateThemeVisual({ + preset: 'custom', + custom: { + colors: { accent: '#c9973f', bg: '#000' }, + fonts: { display: 'Cinzel, Georgia, serif', sans: 'Inter, Arial, sans-serif' }, + structure: { radiusCard: '4px', shadowDepth: SHADOW_OPTIONS[0].value }, + }, + }) + assert.equal(res.ok, true, res.message) +}) + +test('rejects a color that is not a hex literal', () => { + // The point of the closed set: a CSS function or keyword never reaches a + // custom property value, whatever it would or would not have done there. + for (const bad of ['red', 'rgb(1,2,3)', 'url(http://x/y)', '#12345', 'var(--bg)', '#ff0000; x']) { + const res = validateThemeVisual({ preset: 'custom', custom: { colors: { accent: bad } } }) + assert.equal(res.ok, false, bad) + assert.match(res.message, /colors: invalid value for accent/) + } +}) + +test('rejects a font stack that is not on the shortlist', () => { + const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS, sans-serif' } } }) + assert.equal(res.ok, false) + assert.match(res.message, /fonts: invalid value for sans/) +}) + +test('rejects a font offered for a different role', () => { + // Cinzel is a display face and is not in the sans list. + const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Cinzel, Georgia, serif' } } }) + assert.equal(res.ok, false) +}) + +test('rejects an out-of-range or unitless radius', () => { + for (const bad of ['1000px', '4', '4em', '-4px', 'calc(4px + 1px)']) { + const res = validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: bad } } }) + assert.equal(res.ok, false, bad) + } + assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: '999px' } } }).ok, true) +}) + +test('rejects a shadow that is not one of the listed depths', () => { + const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: '0 0 99px red' } } }) + assert.equal(res.ok, false) +}) + +test('rejects unknown groups and unknown fields inside a group', () => { + assert.equal(validateThemeVisual({ preset: 'custom', custom: { spacing: { unit: '8px' } } }).ok, false) + const res = validateThemeVisual({ preset: 'custom', custom: { colors: { border: '#fff' } } }) + assert.equal(res.ok, false) + assert.match(res.message, /invalid value for border/) +}) + +// ── resolveThemeTokens (read path) ──────────────────────────────────── + +// The acceptance criterion the whole feature rests on: absent means absent, and +// the caller writes nothing. +test('no row, or an unusable one, resolves to null', () => { + for (const nothing of [null, undefined, '', 'not json', '4', '"x"', '[]', '{}', '{"preset":"custom"}']) { + assert.equal(resolveThemeTokens(nothing), null, JSON.stringify(nothing)) + } +}) + +test('a preset resolves to its full palette', () => { + const tokens = resolveThemeTokens(JSON.stringify({ preset: 'fantasy' })) + assert.deepEqual(tokens, PRESETS.fantasy.tokens) + // Not a partial palette: the supporting shades move with it, or a warm theme + // keeps dark-blue borders. + assert.equal(tokens['--line'], '#4a3721') + assert.equal(tokens['--blue'], '#382613') +}) + +test('semantic status colors are never themed', () => { + for (const preset of Object.values(PRESETS)) { + assert.equal('--mode-live' in preset.tokens, false) + assert.equal('--mode-maint' in preset.tokens, false) + } +}) + +test('the derived panel gradient is never written as a literal', () => { + for (const preset of Object.values(PRESETS)) { + assert.equal('--panel-grad' in preset.tokens, false) + } +}) + +test('runic-gateway is exactly the shipped stylesheet values', () => { + const t = PRESETS['runic-gateway'].tokens + assert.equal(t['--bg'], '#0e1318') + assert.equal(t['--accent'], '#7f99bd') + assert.equal(t['--radius-pill'], '999px') + assert.equal(t['--radius-panel'], '12px') + assert.equal(t['--radius-card'], '10px') + assert.equal(t['--radius-input'], '8px') + assert.equal(t['--serif'], 'Georgia, "Times New Roman", serif') + assert.equal(t['--display'], 'Cinzel, Georgia, serif') + assert.equal(t['--sans'], '"Helvetica Neue", Arial, sans-serif') +}) + +// The one place the server duplicates the stylesheet, so the one place that can +// drift: switching to runic-gateway after trying another preset must land back +// on exactly what :root ships, not on a stale copy of it. +test('the runic-gateway preset matches theme.css :root token for token', () => { + const fs = require('node:fs') + const path = require('node:path') + const cssPath = path.join(__dirname, '..', '..', 'client', 'src', 'styles', 'theme.css') + const root = /:root\s*\{([\s\S]*?)\}/.exec(fs.readFileSync(cssPath, 'utf8')) + assert.ok(root, 'theme.css has a :root block') + const declared = {} + for (const line of root[1].split(';')) { + const m = /^\s*(--[a-z0-9-]+)\s*:\s*([\s\S]+?)\s*$/i.exec(line.replace(/\/\*[\s\S]*?\*\//g, '')) + if (m) declared[m[1]] = m[2] + } + for (const [token, value] of Object.entries(PRESETS['runic-gateway'].tokens)) { + // --shadow-card is the exception: theme.css writes rgba() unspaced and the + // preset writes it spaced, which is the same computed value. Compare with + // whitespace normalized rather than exempting the token entirely. + assert.equal( + String(declared[token]).replace(/\s+/g, ''), + value.replace(/\s+/g, ''), + `${token} drifted from theme.css`, + ) + } +}) + +test('custom fields layer on top of the preset, per field', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#ffffff' } } }), + ) + assert.equal(tokens['--accent'], '#ffffff') // overridden + assert.equal(tokens['--bg'], PRESETS.fantasy.tokens['--bg']) // untouched + assert.equal(tokens['--radius-card'], '2px') // untouched +}) + +test('custom with no preset base yields only the fields that were set', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'custom', custom: { structure: { radiusCard: '4px' } } }), + ) + assert.deepEqual(tokens, { '--radius-card': '4px' }) +}) + +// Fail-safe, field by field: a hand-edited row degrades to the shipped default +// for the bad field only, rather than rendering a broken site or throwing. +test('an invalid field is dropped without discarding its neighbours', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'custom', custom: { colors: { accent: 'red', bg: '#000000' } } }), + ) + assert.deepEqual(tokens, { '--bg': '#000000' }) +}) + +test('an unknown preset still applies the custom fields', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'parchment', custom: { colors: { accent: '#ffffff' } } }), + ) + assert.deepEqual(tokens, { '--accent': '#ffffff' }) +}) + +test('accepts an already-parsed object as well as the stored string', () => { + assert.deepEqual(resolveThemeTokens({ preset: 'modern' }), PRESETS.modern.tokens) +}) + +test('every resolved value is a plain string', () => { + const tokens = resolveThemeTokens(JSON.stringify({ preset: 'modern' })) + for (const [name, value] of Object.entries(tokens)) { + assert.match(name, /^--[a-z-]+$/, name) + assert.equal(typeof value, 'string', name) + } +}) + +// ── themeOptions (the catalog the admin form is built from) ─────────── + +// The reason the catalog is served rather than duplicated client-side: every +// option offered must be one validateThemeVisual() accepts. +test('every offered font and shadow validates', () => { + const opts = themeOptions() + for (const [role, options] of Object.entries(opts.fonts)) { + for (const o of options) { + const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { [role]: o.value } } }) + assert.equal(res.ok, true, `${role}: ${o.value} — ${res.message || ''}`) + } + } + for (const o of opts.shadows) { + const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: o.value } } }) + assert.equal(res.ok, true, o.value) + } +}) + +test('every offered preset id validates and every field name is editable', () => { + const opts = themeOptions() + for (const p of opts.presets) { + assert.equal(validateThemeVisual({ preset: p.id }).ok, true, p.id) + } + for (const { name } of opts.colorFields) { + assert.equal(validateThemeVisual({ preset: 'custom', custom: { colors: { [name]: '#123456' } } }).ok, true, name) + } + for (const { name } of opts.radiusFields) { + assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { [name]: '5px' } } }).ok, true, name) + } +}) + +// The form reads each control's current value out of the preset map by token +// name, so every advertised field must actually resolve to one. +test('every advertised field names a token the presets declare', () => { + const opts = themeOptions() + for (const { name, token } of [...opts.colorFields, ...opts.radiusFields]) { + assert.ok(token.startsWith('--'), `${name} → ${token}`) + assert.ok(token in opts.shippedTokens, `${token} missing from the shipped theme`) + for (const p of opts.presets) { + if (p.tokens) assert.ok(token in p.tokens, `${token} missing from preset ${p.id}`) + } + } +}) + +test('the shipped default font stacks are reachable from the shortlist', () => { + // An admin who customizes fonts must be able to get back to today's look + // without resetting the whole theme. + const serifValues = FONT_OPTIONS.serif.map((o) => o.value) + const sansValues = FONT_OPTIONS.sans.map((o) => o.value) + const displayValues = FONT_OPTIONS.display.map((o) => o.value) + assert.ok(serifValues.includes('Georgia, "Times New Roman", serif')) + assert.ok(sansValues.includes('"Helvetica Neue", Arial, sans-serif')) + assert.ok(displayValues.includes('Cinzel, Georgia, serif')) +}) From 847cfd2d2b80cc035f6b97c09dda363daf3758dd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 7 Aug 2026 20:09:56 -0500 Subject: [PATCH 3/6] feat(theming): brand-asset overrides and a cached, settings-aware HTML shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` : '' +} + +/** + * 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>${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, /