diff --git a/client/src/api/client.js b/client/src/api/client.js index 57b22c0..4312872 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -292,6 +292,16 @@ export const api = { // keys and the hero draft only (the server holds the allowlist). Idempotent, // so the caller need not know whether a row exists. resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }), + // Upload one brand asset (logo | hero | favicon) and set it as the override + // in the same call → { url, brand_assets }. A separate endpoint from the + // generic upload above because the server applies per-slot rules (favicons + // are PNG-only and capped small) and writes the settings row itself, so an + // upload never leaves a file nothing points at. + uploadBrandAsset: (slot, file) => { + const fd = new FormData() + fd.append('image', file) + return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true }) + }, activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), botActivity: () => req('/admin/bot-activity'), unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), diff --git a/client/src/components/BrandLogo.jsx b/client/src/components/BrandLogo.jsx new file mode 100644 index 0000000..f91ed53 --- /dev/null +++ b/client/src/components/BrandLogo.jsx @@ -0,0 +1,33 @@ +import { useSite } from '../contexts/SiteContext.jsx' + +// The instance logo, shown beside the MoonDot wherever the site says its own +// name (docs/website/THEMING_AND_NAV.md phase 5). +// +// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded +// override or BRAND_LOGO, and its default is the empty string. That is what +// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone +// exactly as it does now, and the logo is an addition an operator opts into. +// +// It sits beside the moon rather than replacing it. The moon is the app's own +// mark and appears on surfaces (maintenance, login) that must render before the +// settings fetch resolves; swapping it out would leave those momentarily blank. +// +// Deliberately not used for the footer's "powered by Runic Gateway" emblem +// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and +// must not follow brand_assets (§4.11). +export default function BrandLogo({ height = 22, alt = '', style }) { + const { brand, siteTitle } = useSite() + if (!brand.logo) return null + return ( + + ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index a3c0341..5b1382f 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -1,5 +1,6 @@ import { Link, NavLink } from 'react-router-dom' import MoonDot from './MoonDot.jsx' +import BrandLogo from './BrandLogo.jsx' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' @@ -68,6 +69,7 @@ export default function SiteHeader() { className="display" style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }} > + {siteTitle} diff --git a/client/src/contexts/SiteContext.jsx b/client/src/contexts/SiteContext.jsx index 71c4a2c..288d40b 100644 --- a/client/src/contexts/SiteContext.jsx +++ b/client/src/contexts/SiteContext.jsx @@ -8,11 +8,16 @@ const SiteContext = createContext(null) export function SiteProvider({ children }) { const [settings, setSettings] = useState({}) const [loading, setLoading] = useState(true) + // Whether a fetch has actually SUCCEEDED, as distinct from `loading` — which + // also goes false when the request failed and we fell back to {}. The boot + // theme handoff below turns on this distinction. + const [settled, setSettled] = useState(false) const refresh = useCallback(async () => { try { const data = await api.publicSettings() setSettings(data || {}) + setSettled(true) } catch { setSettings({}) } finally { @@ -33,7 +38,17 @@ export function SiteProvider({ children }) { const appliedTokens = useRef([]) useEffect(() => { appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current) - }, [settings.theme]) + // Take over from the shell's boot block. The server injects the same tokens + // into so a themed instance does not paint the shipped palette for a + // frame first (utils/htmlShell.js); from here on this effect is the + // authority, and leaving the block behind would mean a later reset removed + // the inline properties only to reveal the stale block underneath. + // + // Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request + // leaves us with no theme at all, and dropping the block then would strip a + // themed instance back to the shipped palette for no reason. + if (settled) document.getElementById('theme-boot')?.remove() + }, [settings.theme, settled]) // Apply the instance accent color to the CSS variable the theme is built on, // so branding flows to every `var(--accent)` at runtime (no rebuild). This is diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 341773b..37e67f2 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import MoonDot from '../../components/MoonDot.jsx' +import BrandLogo from '../../components/BrandLogo.jsx' import { useAuth } from '../../contexts/AuthContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx' @@ -231,6 +232,7 @@ export default function AdminLayout() { }} >
+
diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx index bd71039..d6492ef 100644 --- a/client/src/routes/admin/AdminLogin.jsx +++ b/client/src/routes/admin/AdminLogin.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Link, useNavigate, useLocation } from 'react-router-dom' import MoonDot from '../../components/MoonDot.jsx' +import BrandLogo from '../../components/BrandLogo.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx' import TrustLimitModal from '../../components/security/TrustLimitModal.jsx' import { useAuth } from '../../contexts/AuthContext.jsx' @@ -181,6 +182,10 @@ export default function AdminLogin() {
+ {/* Stacked above the moon rather than beside it: this layout is + centered text, and a flex row here would change the block's + height on instances with no logo. */} +

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

+ + {/* ── Brand assets ───────────────────────────────────────── */} + ) } diff --git a/client/src/routes/admin/views/BrandAssetsPanel.jsx b/client/src/routes/admin/views/BrandAssetsPanel.jsx new file mode 100644 index 0000000..72957f4 --- /dev/null +++ b/client/src/routes/admin/views/BrandAssetsPanel.jsx @@ -0,0 +1,213 @@ +import { useRef, useState } from 'react' +import { api } from '../../../api/client.js' +import { useSite } from '../../../contexts/SiteContext.jsx' + +// Admin · Appearance → Brand assets (docs/website/THEMING_AND_NAV.md §6.3). +// +// Three slots, each an override layer over the matching BRAND_* env value. An +// empty slot is not "no image" — it is "whatever this instance was deployed +// with", which is why every row shows what it currently resolves to rather than +// an empty box. +// +// Unlike the theme form above, an upload SAVES IMMEDIATELY: the file and the +// settings row are written by one request, because an upload that stored a file +// and then waited for a Save press would leave litter in /uploads whenever the +// admin changed their mind. Clearing a slot is the same deal in reverse. +const SLOTS = [ + { + id: 'logo', + label: 'Logo', + accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif', + limit: '1 MB', + envVar: 'BRAND_LOGO', + help: 'Shown beside the moon in the site header, the admin sidebar and the player portal, and used as the link preview image when a page is shared.', + }, + { + id: 'hero', + label: 'Hero image', + accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif', + limit: '8 MB', + envVar: 'BRAND_HERO', + // §4.9: the hero editor's own background beats this, and an admin who does + // not know that files a bug against a working system. + help: 'The image behind the portal hero. If the hero editor has its own background image set, that wins over this one.', + }, + { + id: 'favicon', + label: 'Favicon', + accept: 'image/png', + limit: '512 KB', + envVar: 'BRAND_FAVICON', + // §4.10: .ico would mean adding a type to the upload allowlist, and the + // stored extension coming from that allowlist is what makes uploads safe. + help: 'The browser tab icon. PNG only — a 32×32 or 64×64 square works everywhere.', + }, +] + +export default function BrandAssetsPanel({ initial }) { + const { brand, refresh: refreshSite } = useSite() + const [assets, setAssets] = useState(initial || {}) + const [busySlot, setBusySlot] = useState('') + const [error, setError] = useState('') + const inputs = useRef({}) + + async function upload(slot, file) { + if (!file) return + setBusySlot(slot) + setError('') + try { + const res = await api.admin.uploadBrandAsset(slot, file) + setAssets(res.brand_assets || {}) + await refreshSite() + } catch (err) { + setError(err.message || 'Could not upload that image.') + } finally { + setBusySlot('') + // Let the same file be picked again after a failure — a file input holds + // its value, so re-choosing it would fire no change event. + if (inputs.current[slot]) inputs.current[slot].value = '' + } + } + + async function clear(slot) { + setBusySlot(slot) + setError('') + try { + const next = { ...assets } + delete next[slot] + // Clearing the last override deletes the row rather than storing `{}` — + // absence of the row is what selects the env defaults (§2), and a stored + // empty object would be a different state that means the same thing. + if (Object.keys(next).length) await api.admin.updateSettings({ brand_assets: next }) + else await api.admin.resetSetting('brand_assets') + setAssets(next) + await refreshSite() + } catch (err) { + setError(err.message || 'Could not clear that asset.') + } finally { + setBusySlot('') + } + } + + return ( +
+ Brand assets +
+ {SLOTS.map((slot) => { + const overridden = Boolean(assets[slot.id]) + // What the site actually uses right now: the override, or the env + // value the brand block already resolved for us. + const effective = assets[slot.id] || brand[slot.id] || '' + return ( +
+
+ {effective ? ( + + ) : ( + + none + + )} +
+ +
+
+ {slot.label} +
+
+ {slot.help} +
+
+ {overridden ? ( + <> + Uploaded override — {assets[slot.id]} + + ) : effective ? ( + <> + Using the deployed default from {slot.envVar} + + ) : ( + <> + Not set — {slot.envVar} is empty, so nothing is rendered + + )} +
+ +
+ { + inputs.current[slot.id] = el + }} + type="file" + accept={slot.accept} + disabled={Boolean(busySlot)} + onChange={(e) => upload(slot.id, e.target.files?.[0])} + className="sans" + style={{ fontSize: '0.74rem', maxWidth: 240 }} + aria-label={`Upload a ${slot.label.toLowerCase()}`} + /> + + max {slot.limit} + + {overridden && ( + + )} +
+
+
+ ) + })} +
+ {error && ( + + {error} + + )} + + Uploads apply as soon as they finish — there is nothing to save here. The footer’s “powered by + Runic Gateway” mark is the project’s badge, not this instance’s, and never changes. + +
+ ) +} + +const linkBtn = { + border: 'none', + background: 'transparent', + color: 'var(--accent)', + fontSize: '0.72rem', + cursor: 'pointer', + padding: 0, +} diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx index 0d56625..755c968 100644 --- a/client/src/routes/player/PlayerPortalLayout.jsx +++ b/client/src/routes/player/PlayerPortalLayout.jsx @@ -1,5 +1,6 @@ import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import MoonDot from '../../components/MoonDot.jsx' +import BrandLogo from '../../components/BrandLogo.jsx' import { useAuth } from '../../contexts/AuthContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx' @@ -86,6 +87,7 @@ export default function PlayerPortalLayout() { }} >
+
diff --git a/client/src/routes/player/PlayerShell.jsx b/client/src/routes/player/PlayerShell.jsx index b444054..5fca191 100644 --- a/client/src/routes/player/PlayerShell.jsx +++ b/client/src/routes/player/PlayerShell.jsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' import MoonDot from '../../components/MoonDot.jsx' +import BrandLogo from '../../components/BrandLogo.jsx' import { useSite } from '../../contexts/SiteContext.jsx' // Centered card layout shared by the player login / register pages. `subtitle` @@ -25,6 +26,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
+ {/* Stacked above the moon rather than beside it: this layout is + centered text, and a flex row here would change the block's + height on instances with no logo. */} +

diff --git a/client/src/routes/public/Maintenance.jsx b/client/src/routes/public/Maintenance.jsx index b6b0b5a..f939410 100644 --- a/client/src/routes/public/Maintenance.jsx +++ b/client/src/routes/public/Maintenance.jsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' import MoonDot from '../../components/MoonDot.jsx' +import BrandLogo from '../../components/BrandLogo.jsx' import { useSite } from '../../contexts/SiteContext.jsx' export default function Maintenance() { @@ -28,6 +29,7 @@ export default function Maintenance() { >
+

diff --git a/server/routes.guards.json b/server/routes.guards.json index 3304f14..3b32255 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -625,6 +625,16 @@ "requireAuth" ] }, + { + "method": "POST", + "path": "/api/v1/admin/settings/brand-asset/:slot", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "multerMiddleware" + ] + }, { "method": "POST", "path": "/api/v1/admin/shard/account", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index f91067f..3e97f78 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -253,6 +253,10 @@ "method": "DELETE", "path": "/api/v1/admin/settings/:key" }, + { + "method": "POST", + "path": "/api/v1/admin/settings/brand-asset/:slot" + }, { "method": "POST", "path": "/api/v1/admin/shard/account" diff --git a/server/src/app.js b/server/src/app.js index 211f240..62bfe38 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -16,6 +16,7 @@ const brand = require('./config/brand') const csp = require('./config/csp') const { cspReportLimiter } = require('./middleware/rateLimit') const createLogger = require('./utils/logger') +const htmlShell = require('./utils/htmlShell') const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy') const botScore = require('./middleware/botScore') @@ -96,31 +97,6 @@ const htmlEscape = (s) => (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]), ) -// Template the built index.html with instance branding (title, meta -// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env, -// so the prebuilt SPA image serves per-instance metadata without a rebuild. -function renderIndexHtml(html) { - const title = htmlEscape(brand.name) - const desc = htmlEscape(brand.description) - const tags = [ - ``, - ``, - '', - brand.url ? `` : '', - brand.logo ? `` : '', - '', - ``, - ``, - brand.favicon ? `` : '', - ] - .filter(Boolean) - .join('\n ') - return html - .replace(/[\s\S]*?<\/title>/i, `<title>${title}`) - .replace(/()/i, `$1${desc}$2`) - .replace(/<\/head>/i, ` ${tags}\n `) -} - // Uploaded images — always served, even during maintenance. Force nosniff so a // stored file is never interpreted as anything other than its declared type // (defense in depth alongside helmet's global X-Content-Type-Options, and in @@ -204,9 +180,23 @@ if (fs.existsSync(BRAND_DIR)) { if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) { // Serve a branded copy of the index.html shell for every SPA route; assets keep // their own cache-friendly static handler. - const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8')) + // + // The shell is templated from BRAND_* env *and* the admin's brand_assets / + // theme_visual rows, so it is rendered lazily and cached rather than built once + // at boot: see utils/htmlShell.js for the caching, the invalidation and why a + // DB fault still serves a page. + htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8')) app.use(express.static(CLIENT_DIST, { index: false })) - app.get('*', (req, res) => res.type('html').send(indexHtml)) + app.get('*', async (req, res, next) => { + // htmlShell.get() swallows a settings-read failure itself; the try is for + // anything unforeseen, since an async handler that rejects in Express 4 + // hangs the request instead of reaching the error handler below. + try { + res.type('html').send(await htmlShell.get()) + } catch (err) { + next(err) + } + }) } else { app.get('*', (req, res) => res diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index db708c5..1fa816f 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -2,6 +2,7 @@ const settingsDb = require('./settings.db') const brand = require('../../config/brand') const { parseJsonSetting } = require('../../utils/settingsJson') const { resolveThemeTokens } = require('../../utils/themeResolve') +const { resolveBrandAssets } = require('../../utils/brandAssets') // Keys safe to expose on the public site. const PUBLIC_KEYS = [ @@ -158,10 +159,11 @@ async function getPublic() { // site actually paints. See THEMING_AND_NAV.md §6. const theme = resolveThemeTokens(all.theme_visual) if (theme) out.theme = theme - // Uploaded brand-asset overrides (§6.3). Written by the Phase 5 admin UI; - // resolved here so every consumer of the brand block — the SPA, the Android - // app, the Discord bot — picks them up through the one contract. - const brandAssets = parseJsonSetting(all.brand_assets) || {} + // Uploaded brand-asset overrides (§6.3), resolved here so every consumer of + // the brand block — the SPA, the Android app, the Discord bot — picks them up + // through the one contract. Forgiving on read like the theme: a slot holding + // something we would not emit as a URL is dropped and its neighbours kept. + const brandAssets = resolveBrandAssets(parseJsonSetting(all.brand_assets)) // Instance branding (BRAND_* env defaults). The admin-editable settings — // site title, contact email, and now the theme accent and uploaded assets — // override the env value when set, so existing installs keep their @@ -199,6 +201,28 @@ async function getPublic() { return out } +/** + * What the HTML shell needs, resolved exactly as getPublic() resolves it: the + * effective favicon and logo, plus the theme token map for the boot ` : '' +} + +/** + * Provide the built index.html. Called once at boot by app.js; a separate step + * from get() so the file read stays synchronous and startup still fails loudly + * if the client build is unreadable. + */ +function init(html) { + template = html + cached = null + inflight = null + generation += 1 +} + +/** Drop the cached shell. Called after any write that can change it. */ +function invalidate() { + cached = null + inflight = null + generation += 1 +} + +/** + * The current shell. Renders on a cold or expired cache, otherwise returns the + * cached string. Never rejects: a settings read that fails yields the env-only + * shell. + * + * @returns {Promise} + */ +async function get() { + if (template === null) throw new Error('htmlShell.init() was never called') + if (cached && Date.now() - cached.at < TTL_MS) return cached.html + if (inflight) return inflight + + const startedAt = generation + const run = (async () => { + let overrides = {} + try { + // Required lazily: this module is loaded by app.js at boot, and the + // settings model pulls in the DB pool. Requiring it at the top would make + // the HTML shell a startup-time dependency of the database. + // eslint-disable-next-line global-require + const settings = require('../model/settings/settings.model') + overrides = await settings.getShellBrand() + } catch { + // A DB fault must never fail the page (§4.3). Fall back to the env-only + // shell — the pre-feature behaviour — and cache it, so an outage does not + // mean a failing query per page view. + overrides = {} + } + const html = render(template, overrides) + // An invalidation that landed while this read was in flight means the value + // we just read may already be stale. Serve it, but do not cache it. + if (generation === startedAt) cached = { html, at: Date.now() } + // Only retire our own registration: an invalidation during the read may have + // already started a newer render, and clearing that one would cost an extra + // render on the next request. + if (inflight === run) inflight = null + return html + })() + inflight = run + return run +} + +module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 7f862d8..31e95e3 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3532,6 +3532,132 @@ } } }, + "/api/v1/admin/settings/brand-asset/{slot}": { + "post": { + "tags": [ + "Admin · Settings" + ], + "summary": "Upload a brand asset and set it as the override (admin only)", + "description": "Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.", + "parameters": [ + { + "name": "slot", + "in": "path", + "required": true, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "logo", + "hero", + "favicon" + ], + "items": { + "type": "string" + } + } + } + }, + "description": "Which asset to replace" + } + ], + "responses": { + "201": { + "description": "Stored file URL and the updated overrides", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "/uploads/1712345678901-ab12cd34.png" + }, + "brand_assets": { + "type": "object", + "properties": { + "logo": { + "type": "string" + }, + "hero": { + "type": "string" + }, + "favicon": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "No file, unknown slot, disallowed type, or over the slot size cap", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, "/api/v1/admin/settings/{key}": { "delete": { "tags": [ diff --git a/server/test/brandAssets.test.js b/server/test/brandAssets.test.js new file mode 100644 index 0000000..773f62a --- /dev/null +++ b/server/test/brandAssets.test.js @@ -0,0 +1,352 @@ +// Point the DB at a closed port BEFORE the pool is built, and the upload +// directory at a throwaway one BEFORE imageUpload.js resolves it — both are read +// at require time. Every model call is monkeypatched, so no query runs. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const os = require('os') +const path = require('path') +const fs = require('fs') + +const UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-brand-assets-')) +process.env.UPLOAD_DIR = UPLOAD_DIR + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Phase 5 of docs/website/THEMING_AND_NAV.md: the brand-asset overrides. Two +// halves are worth locking — what a stored value is allowed to be (these values +// are written straight into HTML as URLs) and the upload route's per-slot rules, +// which tighten the shared allowlist without ever widening it (§9). +const { startApp } = require('./_helper') +const { isSafeAssetPath, validateBrandAssets, resolveBrandAssets, SLOTS } = require('../src/utils/brandAssets') +const settingsRouter = require('../src/router/v1/admin/settings.router') +const settingsDb = require('../src/model/settings/settings.db') +const sessionService = require('../src/auth/session.service') +const { requireAuth } = require('../src/auth/session.middleware') +const users = require('../src/model/users/users.model') +const activity = require('../src/model/activity/activity.model') +const htmlShell = require('../src/utils/htmlShell') +const db = require('../src/utils/db') + +after(() => { + db.close() + fs.rmSync(UPLOAD_DIR, { recursive: true, force: true }) +}) + +const originals = { + validateSession: sessionService.validateSession, + isSessionRevoked: sessionService.isSessionRevoked, + sessionMeta: sessionService.sessionMeta, + getById: users.getById, + get: settingsDb.get, + set: settingsDb.set, + log: activity.log, +} +afterEach(() => { + Object.assign(sessionService, { + validateSession: originals.validateSession, + isSessionRevoked: originals.isSessionRevoked, + sessionMeta: originals.sessionMeta, + }) + users.getById = originals.getById + settingsDb.get = originals.get + settingsDb.set = originals.set + activity.log = originals.log +}) + +function signInAs(user) { + sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' }) + sessionService.isSessionRevoked = async () => false + sessionService.sessionMeta = () => ({}) + users.getById = async () => user + activity.log = async () => {} +} + +// ── What a stored asset path may be ─────────────────────────────────── + +test('only same-origin paths under the directories this server serves are accepted', () => { + for (const ok of ['/uploads/1-a.png', '/brand/logo.svg', '/assets/img/runic-emblem.png']) { + assert.equal(isSafeAssetPath(ok), true, `${ok} should be accepted`) + } + const rejected = [ + 'https://evil.example/x.png', // off-origin: an the operator did not choose + '//evil.example/x.png', // protocol-relative — looks like a path, loads off-origin + 'javascript:alert(1)', // no scheme survives the prefix check, but be explicit + '/uploads/../../etc/passwd', // climbing out of the served directory + '/uploads/a b.png', // whitespace is the raw material for smuggling + '/uploads/"onerror="alert(1)', // quote would break out of the attribute + '/etc/passwd', // a path, but not one we serve + 'uploads/1-a.png', // relative to the current route, not to the origin + '', + null, + 42, + ] + for (const bad of rejected) { + assert.equal(isSafeAssetPath(bad), false, `${String(bad)} should be rejected`) + } +}) + +// Strict on write: the admin gets told which field is wrong, rather than saving +// something that silently never renders. +test('a write naming an unknown slot or an unusable path is rejected by field', () => { + assert.equal(validateBrandAssets({ logo: '/uploads/a.png', hero: null }).ok, true) + assert.equal(validateBrandAssets(null).ok, true) // clearing every slot + + const unknown = validateBrandAssets({ banner: '/uploads/a.png' }) + assert.equal(unknown.ok, false) + assert.match(unknown.message, /banner/) + + const offsite = validateBrandAssets({ favicon: 'https://evil.example/f.png' }) + assert.equal(offsite.ok, false) + assert.match(offsite.message, /favicon/) + + assert.equal(validateBrandAssets(['/uploads/a.png']).ok, false) +}) + +// Forgiving on read: one hand-edited slot must not cost the admin the other two. +test('a bad stored slot is dropped and its neighbours are kept', () => { + const resolved = resolveBrandAssets({ logo: '/uploads/a.png', hero: 'https://evil.example/h.png', favicon: null }) + assert.deepEqual(resolved, { logo: '/uploads/a.png' }) +}) + +test('resolve is also how a cleared slot stops being stored', () => { + // '' and null are how the UI clears a slot; neither may survive into the row, + // or "the field is absent" would stop being the single meaning of "use env". + assert.deepEqual(resolveBrandAssets({ logo: '', hero: null }), {}) + assert.deepEqual(resolveBrandAssets(null), {}) + assert.deepEqual(SLOTS, ['logo', 'hero', 'favicon']) +}) + +// ── POST /admin/settings/brand-asset/:slot ──────────────────────────── + +// A 1x1 PNG and a 1x1 GIF, small enough to inline and real enough for multer to +// accept by mimetype (which is what the shared allowlist keys off). +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +) +const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64') + +function form(buffer, { filename = 'x.png', type = 'image/png' } = {}) { + const fd = new FormData() + fd.append('image', new Blob([buffer], { type }), filename) + return fd +} + +const startSettingsApp = () => + startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + +const filesInUploadDir = () => fs.readdirSync(UPLOAD_DIR) + +test('uploading a slot stores the file and points brand_assets at it', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.get = async () => null // never set before + let stored = null + settingsDb.set = async (key, value) => { + stored = { key, value } + } + const before = filesInUploadDir().length + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, { + method: 'POST', + body: form(PNG), + }) + assert.equal(res.status, 201) + const body = await res.json() + assert.match(body.url, /^\/uploads\/\d+-[0-9a-f]{16}\.png$/) + assert.deepEqual(body.brand_assets, { logo: body.url }) + assert.equal(stored.key, 'brand_assets') + assert.deepEqual(JSON.parse(stored.value), { logo: body.url }) + assert.equal(filesInUploadDir().length, before + 1, 'the file is kept') + } finally { + await app.close() + } +}) + +// §6.3: uploading a logo does not force the admin to also pick a hero — and must +// not silently discard the hero they picked last week. +test('an upload merges into the existing overrides rather than replacing them', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.get = async () => JSON.stringify({ hero: '/uploads/existing-hero.png' }) + let stored = null + settingsDb.set = async (key, value) => { + stored = value + } + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, { + method: 'POST', + body: form(PNG), + }) + assert.equal(res.status, 201) + const saved = JSON.parse(stored) + assert.equal(saved.hero, '/uploads/existing-hero.png', 'the hero survives') + assert.match(saved.favicon, /^\/uploads\//) + } finally { + await app.close() + } +}) + +// §4.10: .ico would mean adding a type to MIME_EXT, and the stored extension +// coming from that map is what makes the upload path safe. PNG only, and the +// rejected file does not stay on disk. +test('a favicon that is not a PNG is refused and the file is discarded', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.set = async () => assert.fail('a refused upload must not write the row') + const before = filesInUploadDir().length + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, { + method: 'POST', + body: form(GIF, { filename: 'x.gif', type: 'image/gif' }), + }) + assert.equal(res.status, 400) + assert.match((await res.json()).message, /PNG/) + assert.equal(filesInUploadDir().length, before, 'no orphan file left behind') + } finally { + await app.close() + } +}) + +test('a file over the slot cap is refused and discarded', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.set = async () => assert.fail('a refused upload must not write the row') + // Valid PNG header, then padding past the favicon's 512 KB cap — the shared + // multer limit is 8 MB, so only the per-slot rule can reject this. + const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)]) + const before = filesInUploadDir().length + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, { + method: 'POST', + body: form(big), + }) + assert.equal(res.status, 400) + assert.match((await res.json()).message, /512 KB or smaller/) + assert.equal(filesInUploadDir().length, before) + } finally { + await app.close() + } +}) + +test('the same file is accepted for a slot with a bigger cap', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.get = async () => null + settingsDb.set = async () => {} + const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)]) + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/hero`, { + method: 'POST', + body: form(big), + }) + assert.equal(res.status, 201) + } finally { + await app.close() + } +}) + +test('an unknown slot is refused and the file is discarded', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.set = async () => assert.fail('an unknown slot must not write the row') + const before = filesInUploadDir().length + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/banner`, { + method: 'POST', + body: form(PNG), + }) + assert.equal(res.status, 400) + assert.equal(filesInUploadDir().length, before) + } finally { + await app.close() + } +}) + +// The generic POST /admin/uploads is reachable by editors. The site's identity +// is not theirs to change, so this route carries the same admin gate as the +// settings row it writes. +test('an editor cannot upload a brand asset', async () => { + signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' }) + settingsDb.set = async () => assert.fail('an editor must not write brand_assets') + const before = filesInUploadDir().length + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, { + method: 'POST', + body: form(PNG), + }) + assert.equal(res.status, 403) + assert.equal(filesInUploadDir().length, before, 'the gate runs before multer writes') + } finally { + await app.close() + } +}) + +// ── PUT /admin/settings { brand_assets } — how a slot is CLEARED ────── +// +// There is no per-slot delete route: clearing the logo is a write of the +// remaining slots, and clearing the last one is the existing reset-by-delete. + +test('clearing a slot through the settings write drops it from the row', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + let stored = null + settingsDb.set = async (key, value) => { + stored = value + } + settingsDb.getAll = async () => [] + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ brand_assets: { logo: '/uploads/a.png', hero: null, favicon: '' } }), + }) + assert.equal(res.status, 200) + assert.deepEqual(JSON.parse(stored), { logo: '/uploads/a.png' }, 'no null fields survive into the row') + } finally { + await app.close() + } +}) + +test('a settings write carrying an off-origin asset URL is rejected by field', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.set = async () => assert.fail('an invalid brand_assets must not be stored') + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ brand_assets: { logo: 'https://tracker.example/pixel.png' } }), + }) + assert.equal(res.status, 400) + assert.match((await res.json()).message, /brand_assets\.logo/) + } finally { + await app.close() + } +}) + +test('a successful upload invalidates the cached HTML shell', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.get = async () => null + settingsDb.set = async () => {} + let invalidated = 0 + const realInvalidate = htmlShell.invalidate + htmlShell.invalidate = () => { + invalidated += 1 + } + const app = await startSettingsApp() + try { + const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, { + method: 'POST', + body: form(PNG), + }) + assert.equal(res.status, 201) + assert.equal(invalidated, 1, 'the favicon an admin just uploaded must not wait for the TTL') + } finally { + htmlShell.invalidate = realInvalidate + await app.close() + } +}) diff --git a/server/test/htmlShell.test.js b/server/test/htmlShell.test.js new file mode 100644 index 0000000..03b5b4d --- /dev/null +++ b/server/test/htmlShell.test.js @@ -0,0 +1,229 @@ +// Point the DB at a closed port before the pool is built; the settings read is +// monkeypatched in every test that reaches it, so no query runs. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' +// A brand URL, so the og:image absolutization of an uploaded path is exercised +// rather than being dead code in the test environment. +process.env.BRAND_URL = process.env.BRAND_URL || 'https://shard.example' +// BRAND_LOGO defaults to empty (no logo image rendered), which would make the +// "og:image still comes from env" assertions below pass vacuously. +process.env.BRAND_LOGO = process.env.BRAND_LOGO || '/brand/logo.png' + +const { test, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +// The cached, settings-aware HTML shell (docs/website/THEMING_AND_NAV.md §4.3). +// Three properties are load-bearing enough to lock here: that an untouched +// instance gets byte-for-byte the shell it got before this feature existed, that +// a DB fault still serves a page, and that the steady state is one cached string +// rather than a settings read per page view. +const htmlShell = require('../src/utils/htmlShell') +const settings = require('../src/model/settings/settings.model') +const brand = require('../src/config/brand') +const db = require('../src/utils/db') + +after(() => db.close()) + +const originalGetShellBrand = settings.getShellBrand +afterEach(() => { + settings.getShellBrand = originalGetShellBrand +}) + +// A stand-in for the built client/dist/index.html: the two tags the shell +// rewrites plus the stylesheet link the theme block has to follow. +const TEMPLATE = ` + + + + Vite App + + + +

+` + +// The shell app.js served BEFORE this phase, reproduced verbatim. The point of +// the test is that this string and the new renderer's output are identical for +// an instance with no brand_assets and no theme_visual row (§9), so it is copied +// rather than imported. +function legacyRenderIndexHtml(html) { + const htmlEscape = (s) => + String(s).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]), + ) + const title = htmlEscape(brand.name) + const desc = htmlEscape(brand.description) + const tags = [ + ``, + ``, + '', + brand.url ? `` : '', + brand.logo ? `` : '', + '', + ``, + ``, + brand.favicon ? `` : '', + ] + .filter(Boolean) + .join('\n ') + return html + .replace(/[\s\S]*?<\/title>/i, `<title>${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, /