feat(theming): brand-asset overrides and a cached, settings-aware HTML shell (phase 5) #123
@@ -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 } }),
|
||||
|
||||
33
client/src/components/BrandLogo.jsx
Normal file
33
client/src/components/BrandLogo.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
// The instance logo, shown beside the MoonDot wherever the site says its own
|
||||
// name (docs/website/THEMING_AND_NAV.md phase 5).
|
||||
//
|
||||
// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded
|
||||
// override or BRAND_LOGO, and its default is the empty string. That is what
|
||||
// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone
|
||||
// exactly as it does now, and the logo is an addition an operator opts into.
|
||||
//
|
||||
// It sits beside the moon rather than replacing it. The moon is the app's own
|
||||
// mark and appears on surfaces (maintenance, login) that must render before the
|
||||
// settings fetch resolves; swapping it out would leave those momentarily blank.
|
||||
//
|
||||
// Deliberately not used for the footer's "powered by Runic Gateway" emblem
|
||||
// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and
|
||||
// must not follow brand_assets (§4.11).
|
||||
export default function BrandLogo({ height = 22, alt = '', style }) {
|
||||
const { brand, siteTitle } = useSite()
|
||||
if (!brand.logo) return null
|
||||
return (
|
||||
<img
|
||||
src={brand.logo}
|
||||
// Decorative by default: every call site puts the site title in text right
|
||||
// next to it, so alt text here would have a screen reader say the name
|
||||
// twice. A caller that renders the logo alone passes its own alt.
|
||||
alt={alt || ''}
|
||||
aria-hidden={alt ? undefined : true}
|
||||
title={siteTitle}
|
||||
style={{ height, width: 'auto', maxWidth: height * 6, objectFit: 'contain', display: 'block', ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 }}
|
||||
>
|
||||
<BrandLogo height={22} />
|
||||
<MoonDot />
|
||||
{siteTitle}
|
||||
</Link>
|
||||
|
||||
@@ -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 <head> so a themed instance does not paint the shipped palette for a
|
||||
// frame first (utils/htmlShell.js); from here on this effect is the
|
||||
// authority, and leaving the block behind would mean a later reset removed
|
||||
// the inline properties only to reveal the stale block underneath.
|
||||
//
|
||||
// Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request
|
||||
// leaves us with no theme at all, and dropping the block then would strip a
|
||||
// themed instance back to the shipped palette for no reason.
|
||||
if (settled) document.getElementById('theme-boot')?.remove()
|
||||
}, [settings.theme, settled])
|
||||
|
||||
// Apply the instance accent color to the CSS variable the theme is built on,
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild). This is
|
||||
|
||||
@@ -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() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
|
||||
@@ -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() {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{/* Stacked above the moon rather than beside it: this layout is
|
||||
centered text, and a flex row here would change the block's
|
||||
height on instances with no logo. */}
|
||||
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
{/* ── Brand assets ───────────────────────────────────────── */}
|
||||
<BrandAssetsPanel initial={assets || {}} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal file
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// Admin · Appearance → Brand assets (docs/website/THEMING_AND_NAV.md §6.3).
|
||||
//
|
||||
// Three slots, each an override layer over the matching BRAND_* env value. An
|
||||
// empty slot is not "no image" — it is "whatever this instance was deployed
|
||||
// with", which is why every row shows what it currently resolves to rather than
|
||||
// an empty box.
|
||||
//
|
||||
// Unlike the theme form above, an upload SAVES IMMEDIATELY: the file and the
|
||||
// settings row are written by one request, because an upload that stored a file
|
||||
// and then waited for a Save press would leave litter in /uploads whenever the
|
||||
// admin changed their mind. Clearing a slot is the same deal in reverse.
|
||||
const SLOTS = [
|
||||
{
|
||||
id: 'logo',
|
||||
label: 'Logo',
|
||||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||||
limit: '1 MB',
|
||||
envVar: 'BRAND_LOGO',
|
||||
help: 'Shown beside the moon in the site header, the admin sidebar and the player portal, and used as the link preview image when a page is shared.',
|
||||
},
|
||||
{
|
||||
id: 'hero',
|
||||
label: 'Hero image',
|
||||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||||
limit: '8 MB',
|
||||
envVar: 'BRAND_HERO',
|
||||
// §4.9: the hero editor's own background beats this, and an admin who does
|
||||
// not know that files a bug against a working system.
|
||||
help: 'The image behind the portal hero. If the hero editor has its own background image set, that wins over this one.',
|
||||
},
|
||||
{
|
||||
id: 'favicon',
|
||||
label: 'Favicon',
|
||||
accept: 'image/png',
|
||||
limit: '512 KB',
|
||||
envVar: 'BRAND_FAVICON',
|
||||
// §4.10: .ico would mean adding a type to the upload allowlist, and the
|
||||
// stored extension coming from that allowlist is what makes uploads safe.
|
||||
help: 'The browser tab icon. PNG only — a 32×32 or 64×64 square works everywhere.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function BrandAssetsPanel({ initial }) {
|
||||
const { brand, refresh: refreshSite } = useSite()
|
||||
const [assets, setAssets] = useState(initial || {})
|
||||
const [busySlot, setBusySlot] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const inputs = useRef({})
|
||||
|
||||
async function upload(slot, file) {
|
||||
if (!file) return
|
||||
setBusySlot(slot)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.admin.uploadBrandAsset(slot, file)
|
||||
setAssets(res.brand_assets || {})
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not upload that image.')
|
||||
} finally {
|
||||
setBusySlot('')
|
||||
// Let the same file be picked again after a failure — a file input holds
|
||||
// its value, so re-choosing it would fire no change event.
|
||||
if (inputs.current[slot]) inputs.current[slot].value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function clear(slot) {
|
||||
setBusySlot(slot)
|
||||
setError('')
|
||||
try {
|
||||
const next = { ...assets }
|
||||
delete next[slot]
|
||||
// Clearing the last override deletes the row rather than storing `{}` —
|
||||
// absence of the row is what selects the env defaults (§2), and a stored
|
||||
// empty object would be a different state that means the same thing.
|
||||
if (Object.keys(next).length) await api.admin.updateSettings({ brand_assets: next })
|
||||
else await api.admin.resetSetting('brand_assets')
|
||||
setAssets(next)
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not clear that asset.')
|
||||
} finally {
|
||||
setBusySlot('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="field-label">Brand assets</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 8 }}>
|
||||
{SLOTS.map((slot) => {
|
||||
const overridden = Boolean(assets[slot.id])
|
||||
// What the site actually uses right now: the override, or the env
|
||||
// value the brand block already resolved for us.
|
||||
const effective = assets[slot.id] || brand[slot.id] || ''
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
padding: 12,
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 76,
|
||||
height: 48,
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--line-soft)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-deep)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{effective ? (
|
||||
<img src={effective} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span className="sans dim" style={{ fontSize: '0.68rem' }}>
|
||||
none
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||||
{slot.label}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', lineHeight: 1.6, marginTop: 2 }}>
|
||||
{slot.help}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 6 }}>
|
||||
{overridden ? (
|
||||
<>
|
||||
Uploaded override — <code>{assets[slot.id]}</code>
|
||||
</>
|
||||
) : effective ? (
|
||||
<>
|
||||
Using the deployed default from <code>{slot.envVar}</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Not set — <code>{slot.envVar}</code> is empty, so nothing is rendered
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputs.current[slot.id] = el
|
||||
}}
|
||||
type="file"
|
||||
accept={slot.accept}
|
||||
disabled={Boolean(busySlot)}
|
||||
onChange={(e) => upload(slot.id, e.target.files?.[0])}
|
||||
className="sans"
|
||||
style={{ fontSize: '0.74rem', maxWidth: 240 }}
|
||||
aria-label={`Upload a ${slot.label.toLowerCase()}`}
|
||||
/>
|
||||
<span className="sans dim" style={{ fontSize: '0.7rem' }}>
|
||||
max {slot.limit}
|
||||
</span>
|
||||
{overridden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clear(slot.id)}
|
||||
disabled={Boolean(busySlot)}
|
||||
className="sans"
|
||||
title={`Go back to ${slot.envVar}`}
|
||||
style={linkBtn}
|
||||
>
|
||||
{busySlot === slot.id ? 'working…' : 'clear'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: '#d98b84', fontSize: '0.85rem' }}>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
Uploads apply as soon as they finish — there is nothing to save here. The footer’s “powered by
|
||||
Runic Gateway” mark is the project’s badge, not this instance’s, and never changes.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
@@ -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() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
|
||||
@@ -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 }) {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{/* Stacked above the moon rather than beside it: this layout is
|
||||
centered text, and a flex row here would change the block's
|
||||
height on instances with no logo. */}
|
||||
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
||||
<div style={{ marginBottom: 26 }}>
|
||||
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
|
||||
<MoonDot size={18} glow={0.6} />
|
||||
</div>
|
||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -16,6 +16,7 @@ const brand = require('./config/brand')
|
||||
const csp = require('./config/csp')
|
||||
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||
const createLogger = require('./utils/logger')
|
||||
const htmlShell = require('./utils/htmlShell')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
|
||||
@@ -96,31 +97,6 @@ const htmlEscape = (s) =>
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
|
||||
// Template the built index.html <head> with instance branding (title, meta
|
||||
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
|
||||
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
|
||||
function renderIndexHtml(html) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
||||
// stored file is never interpreted as anything other than its declared type
|
||||
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
||||
@@ -204,9 +180,23 @@ if (fs.existsSync(BRAND_DIR)) {
|
||||
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
||||
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
||||
// their own cache-friendly static handler.
|
||||
const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
//
|
||||
// The shell is templated from BRAND_* env *and* the admin's brand_assets /
|
||||
// theme_visual rows, so it is rendered lazily and cached rather than built once
|
||||
// at boot: see utils/htmlShell.js for the caching, the invalidation and why a
|
||||
// DB fault still serves a page.
|
||||
htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
app.use(express.static(CLIENT_DIST, { index: false }))
|
||||
app.get('*', (req, res) => res.type('html').send(indexHtml))
|
||||
app.get('*', async (req, res, next) => {
|
||||
// htmlShell.get() swallows a settings-read failure itself; the try is for
|
||||
// anything unforeseen, since an async handler that rejects in Express 4
|
||||
// hangs the request instead of reaching the error handler below.
|
||||
try {
|
||||
res.type('html').send(await htmlShell.get())
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
app.get('*', (req, res) =>
|
||||
res
|
||||
|
||||
@@ -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 <style>
|
||||
* block. Kept here rather than in utils/htmlShell.js so there is one authority
|
||||
* for "which asset wins", and so the shell can never disagree with the payload
|
||||
* the SPA fetches a moment later.
|
||||
*
|
||||
* Throws on a DB fault — the caller (utils/htmlShell.js) decides what a failure
|
||||
* means for the page, and for it the answer is "serve the env-only shell".
|
||||
*
|
||||
* @returns {Promise<{logo: string, favicon: string, theme: object|null}>}
|
||||
*/
|
||||
async function getShellBrand() {
|
||||
const all = await getAll()
|
||||
const assets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
|
||||
return {
|
||||
logo: assets.logo || brand.logo,
|
||||
favicon: assets.favicon || brand.favicon,
|
||||
theme: resolveThemeTokens(all.theme_visual),
|
||||
}
|
||||
}
|
||||
|
||||
// The two nav-override keys their own audiences need but cannot read from
|
||||
// GET /admin/settings (admin-only, while AdminLayout renders for editors and
|
||||
// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md
|
||||
@@ -230,6 +254,7 @@ module.exports = {
|
||||
setMany,
|
||||
getAll,
|
||||
getPublic,
|
||||
getShellBrand,
|
||||
getNav,
|
||||
getInstanceName,
|
||||
PUBLIC_KEYS,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const fs = require('fs')
|
||||
|
||||
const posts = require('../../../model/posts/posts.model')
|
||||
const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
@@ -11,6 +13,8 @@ const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
||||
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
||||
const htmlShell = require('../../../utils/htmlShell')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
|
||||
@@ -547,8 +551,28 @@ async function updateSettings(req, res) {
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.theme_visual = JSON.stringify(parsed)
|
||||
}
|
||||
// brand_assets holds the only settings values written straight into HTML the
|
||||
// browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
|
||||
// the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
|
||||
// dropped rather than stored as null, keeping "a field is absent" the single
|
||||
// meaning of "falls back to BRAND_* env".
|
||||
if ('brand_assets' in updates) {
|
||||
const raw = updates.brand_assets
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: 'brand_assets must be a JSON object' })
|
||||
}
|
||||
const check = validateBrandAssets(parsed)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
|
||||
}
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||
// to say so, or the favicon an admin just uploaded appears only after the
|
||||
// cache's TTL.
|
||||
if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
||||
return res.json(await settings.getAll())
|
||||
} catch (err) {
|
||||
@@ -557,6 +581,87 @@ async function updateSettings(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Brand assets ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
|
||||
// itself is never widened (§9: "no second upload path with weaker validation") —
|
||||
// these only ever tighten it:
|
||||
//
|
||||
// • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
|
||||
// fact that the stored extension comes from that map is exactly what makes
|
||||
// the upload path safe (§4.10). Every browser this app supports takes a PNG
|
||||
// icon. Small cap: a favicon is a handful of KB.
|
||||
// • logo — a header mark next to the site title, not a page image.
|
||||
// • hero — a full-bleed background, so it keeps the shared ceiling.
|
||||
//
|
||||
// The cap is checked after multer has written the file rather than by a second
|
||||
// multer instance: one upload config, one allowlist, and the oversized file is
|
||||
// unlinked before we answer.
|
||||
const ASSET_RULES = {
|
||||
logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
|
||||
hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
|
||||
favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
|
||||
}
|
||||
|
||||
const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)
|
||||
|
||||
// Best-effort cleanup of a file we have decided not to keep. A failure here is
|
||||
// a stray file in /uploads, not something the caller can act on.
|
||||
async function discardUpload(file) {
|
||||
try {
|
||||
await fs.promises.unlink(file.path)
|
||||
} catch (err) {
|
||||
log.error('discardUpload', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
|
||||
* brand_assets row at it in the same call.
|
||||
*
|
||||
* One call rather than "upload, then PUT the settings row": a half-completed
|
||||
* save would otherwise leave a file in /uploads that nothing references, and the
|
||||
* per-slot rules above need the slot at upload time anyway. Admin-only, matching
|
||||
* the gate on the settings it writes — POST /admin/uploads is reachable by
|
||||
* editors, who have no business changing the site's identity.
|
||||
*/
|
||||
async function uploadBrandAsset(req, res) {
|
||||
const { slot } = req.params
|
||||
const rules = ASSET_RULES[slot]
|
||||
if (!rules) {
|
||||
if (req.file) await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
||||
if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be a PNG image` })
|
||||
}
|
||||
if (req.file.size > rules.maxBytes) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
|
||||
}
|
||||
|
||||
const url = `/uploads/${req.file.filename}`
|
||||
try {
|
||||
// Read-modify-write the row: uploading a logo must not clear a hero the
|
||||
// admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
|
||||
// with one bad slot does not block setting another.
|
||||
const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
|
||||
const next = { ...current, [slot]: url }
|
||||
await settings.set('brand_assets', JSON.stringify(next), req.user.id)
|
||||
htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
|
||||
return res.status(201).json({ url, brand_assets: next })
|
||||
} catch (err) {
|
||||
log.error('uploadBrandAsset', err)
|
||||
// The row is the point of the call; a stored file nothing points at is
|
||||
// litter, so it goes back out with the error.
|
||||
await discardUpload(req.file)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Delete one settings row — the "reset to defaults" primitive.
|
||||
//
|
||||
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
|
||||
@@ -576,6 +681,7 @@ async function deleteSetting(req, res) {
|
||||
}
|
||||
try {
|
||||
await settings.remove(key)
|
||||
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.reset', detail: { key } })
|
||||
return res.json({ message: 'Setting reset to default' })
|
||||
} catch (err) {
|
||||
@@ -810,6 +916,8 @@ module.exports = {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
deleteSetting,
|
||||
uploadBrandAsset,
|
||||
ASSET_RULES,
|
||||
listActivity,
|
||||
listUsers,
|
||||
createUser,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const settingsRouter = express.Router()
|
||||
@@ -41,6 +42,26 @@ settingsRouter.put(
|
||||
adminOnly,
|
||||
ctrl.updateSettings,
|
||||
)
|
||||
// Upload one brand asset (logo/hero/favicon) and point brand_assets at it in the
|
||||
// same call — see the controller for why it is one call and not "upload, then
|
||||
// PUT". Uses the shared multer config (one upload directory, one mimetype
|
||||
// allowlist); the per-slot PNG rule and size caps are applied in the handler.
|
||||
settingsRouter.post(
|
||||
'/brand-asset/:slot',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Upload a brand asset and set it as the override (admin only)'
|
||||
// #swagger.description = 'Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.parameters['slot'] = { in: 'path', required: true, description: 'Which asset to replace', schema: { type: 'string', enum: ['logo', 'hero', 'favicon'] } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Stored file URL and the updated overrides', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", example: "/uploads/1712345678901-ab12cd34.png" }, brand_assets: { type: "object", properties: { logo: { type: "string" }, hero: { type: "string" }, favicon: { type: "string" } } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No file, unknown slot, disallowed type, or over the slot size cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
upload.single('image'),
|
||||
ctrl.uploadBrandAsset,
|
||||
)
|
||||
// Reset one setting to its default by deleting the row. Only the keys whose
|
||||
// default lives outside the store (theming, nav, hero draft) are deletable —
|
||||
// the controller holds the allowlist.
|
||||
|
||||
95
server/src/utils/brandAssets.js
Normal file
95
server/src/utils/brandAssets.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Uploaded brand-asset overrides — the `brand_assets` settings row.
|
||||
//
|
||||
// { "logo": "/uploads/1234-ab.png", "hero": null, "favicon": null }
|
||||
//
|
||||
// Each field, once set, holds a stored upload URL; a null or absent field falls
|
||||
// back to brand.logo / brand.hero / brand.favicon from BRAND_* env. Uploading a
|
||||
// logo does not force the admin to also pick a hero
|
||||
// (docs/website/THEMING_AND_NAV.md §6.3).
|
||||
//
|
||||
// These values are the only part of the settings store that is written straight
|
||||
// into HTML the browser then fetches — an <img src>, a <link rel="icon">, an
|
||||
// og:image. So the accepted shape is deliberately narrow: a same-origin path
|
||||
// under one of the three directories this app serves, and nothing else. No
|
||||
// scheme, no protocol-relative `//host`, no `..`. The upload route only ever
|
||||
// produces `/uploads/…`, so the other two prefixes exist for an admin who wants
|
||||
// to point at an asset already baked into the image or mounted at /brand.
|
||||
//
|
||||
// Same strict-on-write / forgiving-on-read asymmetry as the theme
|
||||
// (utils/themeResolve.js): a bad write is rejected with the field named, while a
|
||||
// bad *stored* value is dropped field by field so a hand-edited row degrades to
|
||||
// the env default instead of rendering a broken page.
|
||||
|
||||
// The three overridable assets, in the order the admin UI shows them.
|
||||
const SLOTS = ['logo', 'hero', 'favicon']
|
||||
|
||||
// Directories this server actually serves: /uploads (UPLOAD_DIR), /brand
|
||||
// (BRAND_DIR, optional) and /assets (the built SPA's static files).
|
||||
const ALLOWED_PREFIXES = ['/uploads/', '/brand/', '/assets/']
|
||||
|
||||
/**
|
||||
* Is this a value we are willing to emit as a URL into the page?
|
||||
* @param {unknown} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeAssetPath(value) {
|
||||
if (typeof value !== 'string' || value === '') return false
|
||||
// A leading `//` is protocol-relative and would load from another origin
|
||||
// despite looking like a path; `..` could climb out of the served directory.
|
||||
if (value.startsWith('//') || value.includes('..')) return false
|
||||
// Whitespace and control characters have no place in a stored path and are the
|
||||
// raw material for `javascript:` smuggling past a naive prefix check.
|
||||
if (/[\s<>"'\\]/.test(value)) return false
|
||||
return ALLOWED_PREFIXES.some((prefix) => value.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a brand_assets object for WRITING. Strict: names the offending field.
|
||||
* @param {unknown} value the parsed object (or null to clear every slot)
|
||||
* @returns {{ok: true} | {ok: false, message: string}}
|
||||
*/
|
||||
function validateBrandAssets(value) {
|
||||
if (value === null || value === undefined) return { ok: true }
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ok: false, message: 'brand_assets must be a JSON object' }
|
||||
}
|
||||
for (const [slot, url] of Object.entries(value)) {
|
||||
if (!SLOTS.includes(slot)) {
|
||||
return { ok: false, message: `Unknown brand asset '${slot}'` }
|
||||
}
|
||||
// null/'' is how a slot is cleared back to the env default — allowed, and
|
||||
// stripped by the caller so the stored row never carries dead fields.
|
||||
if (url === null || url === '') continue
|
||||
if (!isSafeAssetPath(url)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `brand_assets.${slot} must be an uploaded path under /uploads/, /brand/ or /assets/`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the slots that hold a usable path. Serves both directions on
|
||||
* purpose:
|
||||
*
|
||||
* • writing — an admin who removes their logo stores `{}` (and the caller
|
||||
* deletes the row entirely) rather than a row full of nulls, which would
|
||||
* read as "set to nothing" rather than "never set";
|
||||
* • reading — an unusable stored field is dropped and its neighbours kept, so
|
||||
* one bad slot cannot cost the admin the other two.
|
||||
*
|
||||
* @param {object|null} value an object, or a parseJsonSetting result
|
||||
* @returns {{logo?: string, hero?: string, favicon?: string}}
|
||||
*/
|
||||
function resolveBrandAssets(value) {
|
||||
const out = {}
|
||||
if (!value || typeof value !== 'object') return out
|
||||
for (const slot of SLOTS) {
|
||||
if (isSafeAssetPath(value[slot])) out[slot] = value[slot]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { SLOTS, ALLOWED_PREFIXES, isSafeAssetPath, validateBrandAssets, resolveBrandAssets }
|
||||
187
server/src/utils/htmlShell.js
Normal file
187
server/src/utils/htmlShell.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// The SPA's HTML shell: index.html templated with this instance's branding.
|
||||
//
|
||||
// This used to be a one-liner at module load in app.js — read the built
|
||||
// index.html, template it from BRAND_* env, serve that one string forever. The
|
||||
// admin-configurable brand assets (docs/website/THEMING_AND_NAV.md §4.3) make
|
||||
// the favicon and OG image settings-driven, which is a lifecycle change rather
|
||||
// than an `await`: the shell now depends on a row that can change while the
|
||||
// process runs.
|
||||
//
|
||||
// Three properties this module exists to guarantee:
|
||||
//
|
||||
// • It is a cached string in the steady state. A settings read per page view
|
||||
// would put the database on the critical path of every SPA route, including
|
||||
// during an outage where the API is already degraded.
|
||||
// • A DB fault never fails the page. A read error renders the env-only shell —
|
||||
// exactly what the code did before this feature — and that fallback is
|
||||
// cached like any other, so an outage cannot turn every page view into a
|
||||
// failing query.
|
||||
// • With no brand_assets and no theme_visual row it is BYTE-IDENTICAL to what
|
||||
// app.js served before. That is an acceptance criterion of §9, and the
|
||||
// reason the theme <style> block and the asset overrides are appended only
|
||||
// when they exist rather than always emitted with default values.
|
||||
//
|
||||
// Invalidation is explicit — the settings controller calls invalidate() after a
|
||||
// successful write to brand_assets or theme_visual — with a TTL as a safety net.
|
||||
// The cache is per process: in a scaled deployment the process that handled the
|
||||
// write is the only one that learns of it, so without the TTL every other worker
|
||||
// would serve the old favicon until the next restart.
|
||||
|
||||
const brand = require('../config/brand')
|
||||
|
||||
// How long a rendered shell is trusted without an explicit invalidation. Short
|
||||
// enough that a second process converges on its own, long enough that this is
|
||||
// still one render per process per five minutes rather than one per request.
|
||||
const TTL_MS = 5 * 60 * 1000
|
||||
|
||||
// A stored theme reaches the browser twice: in this block, and again as inline
|
||||
// properties once the SPA has fetched /public/settings. The block exists purely
|
||||
// so a themed instance does not paint the shipped palette for one frame first;
|
||||
// the client drops it (by id) as soon as it has the authoritative payload — see
|
||||
// contexts/SiteContext.jsx.
|
||||
const THEME_STYLE_ID = 'theme-boot'
|
||||
|
||||
// Belt and braces over the theme validators. Every token name comes from a fixed
|
||||
// map and every value from a closed set (hex color, curated font stack, bounded
|
||||
// px, listed shadow), so nothing that reaches here can carry markup today. These
|
||||
// two patterns make that a property of the HTML writer rather than of a validator
|
||||
// three modules away that someone may one day loosen.
|
||||
const SAFE_TOKEN_NAME = /^--[a-zA-Z0-9-_]+$/
|
||||
const SAFE_TOKEN_VALUE = /^[a-zA-Z0-9 ,.()#%_'"/-]+$/
|
||||
|
||||
let template = null // the built index.html, read once
|
||||
let cached = null // { html, at }
|
||||
let inflight = null // de-dupes a burst of requests on a cold cache
|
||||
let generation = 0 // bumped by invalidate(); an in-flight render checks it
|
||||
|
||||
// Escape user/brand text for safe interpolation into the HTML shell.
|
||||
function htmlEscape(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded asset path is always relative (`/uploads/…`), but og:image is read
|
||||
* off-site by scrapers that handle a relative URL poorly. Absolutize it against
|
||||
* BRAND_URL when we have one.
|
||||
*
|
||||
* Env values pass through untouched even when relative: the shell an instance
|
||||
* gets today is the operator's choice and must not change just because this
|
||||
* module now exists.
|
||||
*/
|
||||
function absolutize(url) {
|
||||
if (!brand.url || !url.startsWith('/')) return url
|
||||
return `${brand.url.replace(/\/+$/, '')}${url}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the shell. Pure — every input is a parameter, so a test can assert the
|
||||
* byte-identical property without a database.
|
||||
*
|
||||
* @param {string} html the built index.html
|
||||
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
|
||||
* effective brand assets and theme; anything absent falls back to BRAND_* env
|
||||
* @returns {string}
|
||||
*/
|
||||
function render(html, overrides = {}) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
// Effective values: an uploaded override wins over env, absence means env.
|
||||
const logo = overrides.logo ? absolutize(overrides.logo) : brand.logo
|
||||
const favicon = overrides.favicon || brand.favicon
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
logo ? `<meta property="og:image" content="${htmlEscape(logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
|
||||
themeStyleTag(overrides.theme),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// The admin theme as a :root block, or '' when this instance has never been
|
||||
// themed. Injected last in <head> so it follows the built stylesheet and wins
|
||||
// the equal-specificity tie against theme.css's own :root.
|
||||
function themeStyleTag(theme) {
|
||||
if (!theme || typeof theme !== 'object') return ''
|
||||
const decls = Object.entries(theme)
|
||||
.filter(([name, value]) => SAFE_TOKEN_NAME.test(name) && typeof value === 'string' && SAFE_TOKEN_VALUE.test(value))
|
||||
.map(([name, value]) => `${name}:${value}`)
|
||||
.join(';')
|
||||
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the built index.html. Called once at boot by app.js; a separate step
|
||||
* from get() so the file read stays synchronous and startup still fails loudly
|
||||
* if the client build is unreadable.
|
||||
*/
|
||||
function init(html) {
|
||||
template = html
|
||||
cached = null
|
||||
inflight = null
|
||||
generation += 1
|
||||
}
|
||||
|
||||
/** Drop the cached shell. Called after any write that can change it. */
|
||||
function invalidate() {
|
||||
cached = null
|
||||
inflight = null
|
||||
generation += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The current shell. Renders on a cold or expired cache, otherwise returns the
|
||||
* cached string. Never rejects: a settings read that fails yields the env-only
|
||||
* shell.
|
||||
*
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function get() {
|
||||
if (template === null) throw new Error('htmlShell.init() was never called')
|
||||
if (cached && Date.now() - cached.at < TTL_MS) return cached.html
|
||||
if (inflight) return inflight
|
||||
|
||||
const startedAt = generation
|
||||
const run = (async () => {
|
||||
let overrides = {}
|
||||
try {
|
||||
// Required lazily: this module is loaded by app.js at boot, and the
|
||||
// settings model pulls in the DB pool. Requiring it at the top would make
|
||||
// the HTML shell a startup-time dependency of the database.
|
||||
// eslint-disable-next-line global-require
|
||||
const settings = require('../model/settings/settings.model')
|
||||
overrides = await settings.getShellBrand()
|
||||
} catch {
|
||||
// A DB fault must never fail the page (§4.3). Fall back to the env-only
|
||||
// shell — the pre-feature behaviour — and cache it, so an outage does not
|
||||
// mean a failing query per page view.
|
||||
overrides = {}
|
||||
}
|
||||
const html = render(template, overrides)
|
||||
// An invalidation that landed while this read was in flight means the value
|
||||
// we just read may already be stale. Serve it, but do not cache it.
|
||||
if (generation === startedAt) cached = { html, at: Date.now() }
|
||||
// Only retire our own registration: an invalidation during the read may have
|
||||
// already started a newer render, and clearing that one would cost an extra
|
||||
// render on the next request.
|
||||
if (inflight === run) inflight = null
|
||||
return html
|
||||
})()
|
||||
inflight = run
|
||||
return run
|
||||
}
|
||||
|
||||
module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID }
|
||||
@@ -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": [
|
||||
|
||||
352
server/test/brandAssets.test.js
Normal file
352
server/test/brandAssets.test.js
Normal file
@@ -0,0 +1,352 @@
|
||||
// Point the DB at a closed port BEFORE the pool is built, and the upload
|
||||
// directory at a throwaway one BEFORE imageUpload.js resolves it — both are read
|
||||
// at require time. Every model call is monkeypatched, so no query runs.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-brand-assets-'))
|
||||
process.env.UPLOAD_DIR = UPLOAD_DIR
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Phase 5 of docs/website/THEMING_AND_NAV.md: the brand-asset overrides. Two
|
||||
// halves are worth locking — what a stored value is allowed to be (these values
|
||||
// are written straight into HTML as URLs) and the upload route's per-slot rules,
|
||||
// which tighten the shared allowlist without ever widening it (§9).
|
||||
const { startApp } = require('./_helper')
|
||||
const { isSafeAssetPath, validateBrandAssets, resolveBrandAssets, SLOTS } = require('../src/utils/brandAssets')
|
||||
const settingsRouter = require('../src/router/v1/admin/settings.router')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const { requireAuth } = require('../src/auth/session.middleware')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const htmlShell = require('../src/utils/htmlShell')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => {
|
||||
db.close()
|
||||
fs.rmSync(UPLOAD_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const originals = {
|
||||
validateSession: sessionService.validateSession,
|
||||
isSessionRevoked: sessionService.isSessionRevoked,
|
||||
sessionMeta: sessionService.sessionMeta,
|
||||
getById: users.getById,
|
||||
get: settingsDb.get,
|
||||
set: settingsDb.set,
|
||||
log: activity.log,
|
||||
}
|
||||
afterEach(() => {
|
||||
Object.assign(sessionService, {
|
||||
validateSession: originals.validateSession,
|
||||
isSessionRevoked: originals.isSessionRevoked,
|
||||
sessionMeta: originals.sessionMeta,
|
||||
})
|
||||
users.getById = originals.getById
|
||||
settingsDb.get = originals.get
|
||||
settingsDb.set = originals.set
|
||||
activity.log = originals.log
|
||||
})
|
||||
|
||||
function signInAs(user) {
|
||||
sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' })
|
||||
sessionService.isSessionRevoked = async () => false
|
||||
sessionService.sessionMeta = () => ({})
|
||||
users.getById = async () => user
|
||||
activity.log = async () => {}
|
||||
}
|
||||
|
||||
// ── What a stored asset path may be ───────────────────────────────────
|
||||
|
||||
test('only same-origin paths under the directories this server serves are accepted', () => {
|
||||
for (const ok of ['/uploads/1-a.png', '/brand/logo.svg', '/assets/img/runic-emblem.png']) {
|
||||
assert.equal(isSafeAssetPath(ok), true, `${ok} should be accepted`)
|
||||
}
|
||||
const rejected = [
|
||||
'https://evil.example/x.png', // off-origin: an <img src> the operator did not choose
|
||||
'//evil.example/x.png', // protocol-relative — looks like a path, loads off-origin
|
||||
'javascript:alert(1)', // no scheme survives the prefix check, but be explicit
|
||||
'/uploads/../../etc/passwd', // climbing out of the served directory
|
||||
'/uploads/a b.png', // whitespace is the raw material for smuggling
|
||||
'/uploads/"onerror="alert(1)', // quote would break out of the attribute
|
||||
'/etc/passwd', // a path, but not one we serve
|
||||
'uploads/1-a.png', // relative to the current route, not to the origin
|
||||
'',
|
||||
null,
|
||||
42,
|
||||
]
|
||||
for (const bad of rejected) {
|
||||
assert.equal(isSafeAssetPath(bad), false, `${String(bad)} should be rejected`)
|
||||
}
|
||||
})
|
||||
|
||||
// Strict on write: the admin gets told which field is wrong, rather than saving
|
||||
// something that silently never renders.
|
||||
test('a write naming an unknown slot or an unusable path is rejected by field', () => {
|
||||
assert.equal(validateBrandAssets({ logo: '/uploads/a.png', hero: null }).ok, true)
|
||||
assert.equal(validateBrandAssets(null).ok, true) // clearing every slot
|
||||
|
||||
const unknown = validateBrandAssets({ banner: '/uploads/a.png' })
|
||||
assert.equal(unknown.ok, false)
|
||||
assert.match(unknown.message, /banner/)
|
||||
|
||||
const offsite = validateBrandAssets({ favicon: 'https://evil.example/f.png' })
|
||||
assert.equal(offsite.ok, false)
|
||||
assert.match(offsite.message, /favicon/)
|
||||
|
||||
assert.equal(validateBrandAssets(['/uploads/a.png']).ok, false)
|
||||
})
|
||||
|
||||
// Forgiving on read: one hand-edited slot must not cost the admin the other two.
|
||||
test('a bad stored slot is dropped and its neighbours are kept', () => {
|
||||
const resolved = resolveBrandAssets({ logo: '/uploads/a.png', hero: 'https://evil.example/h.png', favicon: null })
|
||||
assert.deepEqual(resolved, { logo: '/uploads/a.png' })
|
||||
})
|
||||
|
||||
test('resolve is also how a cleared slot stops being stored', () => {
|
||||
// '' and null are how the UI clears a slot; neither may survive into the row,
|
||||
// or "the field is absent" would stop being the single meaning of "use env".
|
||||
assert.deepEqual(resolveBrandAssets({ logo: '', hero: null }), {})
|
||||
assert.deepEqual(resolveBrandAssets(null), {})
|
||||
assert.deepEqual(SLOTS, ['logo', 'hero', 'favicon'])
|
||||
})
|
||||
|
||||
// ── POST /admin/settings/brand-asset/:slot ────────────────────────────
|
||||
|
||||
// A 1x1 PNG and a 1x1 GIF, small enough to inline and real enough for multer to
|
||||
// accept by mimetype (which is what the shared allowlist keys off).
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')
|
||||
|
||||
function form(buffer, { filename = 'x.png', type = 'image/png' } = {}) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', new Blob([buffer], { type }), filename)
|
||||
return fd
|
||||
}
|
||||
|
||||
const startSettingsApp = () =>
|
||||
startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
|
||||
const filesInUploadDir = () => fs.readdirSync(UPLOAD_DIR)
|
||||
|
||||
test('uploading a slot stores the file and points brand_assets at it', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => null // never set before
|
||||
let stored = null
|
||||
settingsDb.set = async (key, value) => {
|
||||
stored = { key, value }
|
||||
}
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
const body = await res.json()
|
||||
assert.match(body.url, /^\/uploads\/\d+-[0-9a-f]{16}\.png$/)
|
||||
assert.deepEqual(body.brand_assets, { logo: body.url })
|
||||
assert.equal(stored.key, 'brand_assets')
|
||||
assert.deepEqual(JSON.parse(stored.value), { logo: body.url })
|
||||
assert.equal(filesInUploadDir().length, before + 1, 'the file is kept')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// §6.3: uploading a logo does not force the admin to also pick a hero — and must
|
||||
// not silently discard the hero they picked last week.
|
||||
test('an upload merges into the existing overrides rather than replacing them', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => JSON.stringify({ hero: '/uploads/existing-hero.png' })
|
||||
let stored = null
|
||||
settingsDb.set = async (key, value) => {
|
||||
stored = value
|
||||
}
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
const saved = JSON.parse(stored)
|
||||
assert.equal(saved.hero, '/uploads/existing-hero.png', 'the hero survives')
|
||||
assert.match(saved.favicon, /^\/uploads\//)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// §4.10: .ico would mean adding a type to MIME_EXT, and the stored extension
|
||||
// coming from that map is what makes the upload path safe. PNG only, and the
|
||||
// rejected file does not stay on disk.
|
||||
test('a favicon that is not a PNG is refused and the file is discarded', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('a refused upload must not write the row')
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
|
||||
method: 'POST',
|
||||
body: form(GIF, { filename: 'x.gif', type: 'image/gif' }),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.match((await res.json()).message, /PNG/)
|
||||
assert.equal(filesInUploadDir().length, before, 'no orphan file left behind')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a file over the slot cap is refused and discarded', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('a refused upload must not write the row')
|
||||
// Valid PNG header, then padding past the favicon's 512 KB cap — the shared
|
||||
// multer limit is 8 MB, so only the per-slot rule can reject this.
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
|
||||
method: 'POST',
|
||||
body: form(big),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.match((await res.json()).message, /512 KB or smaller/)
|
||||
assert.equal(filesInUploadDir().length, before)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('the same file is accepted for a slot with a bigger cap', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => null
|
||||
settingsDb.set = async () => {}
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/hero`, {
|
||||
method: 'POST',
|
||||
body: form(big),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown slot is refused and the file is discarded', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('an unknown slot must not write the row')
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/banner`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.equal(filesInUploadDir().length, before)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// The generic POST /admin/uploads is reachable by editors. The site's identity
|
||||
// is not theirs to change, so this route carries the same admin gate as the
|
||||
// settings row it writes.
|
||||
test('an editor cannot upload a brand asset', async () => {
|
||||
signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('an editor must not write brand_assets')
|
||||
const before = filesInUploadDir().length
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal(filesInUploadDir().length, before, 'the gate runs before multer writes')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── PUT /admin/settings { brand_assets } — how a slot is CLEARED ──────
|
||||
//
|
||||
// There is no per-slot delete route: clearing the logo is a write of the
|
||||
// remaining slots, and clearing the last one is the existing reset-by-delete.
|
||||
|
||||
test('clearing a slot through the settings write drops it from the row', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
let stored = null
|
||||
settingsDb.set = async (key, value) => {
|
||||
stored = value
|
||||
}
|
||||
settingsDb.getAll = async () => []
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brand_assets: { logo: '/uploads/a.png', hero: null, favicon: '' } }),
|
||||
})
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(JSON.parse(stored), { logo: '/uploads/a.png' }, 'no null fields survive into the row')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a settings write carrying an off-origin asset URL is rejected by field', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = async () => assert.fail('an invalid brand_assets must not be stored')
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brand_assets: { logo: 'https://tracker.example/pixel.png' } }),
|
||||
})
|
||||
assert.equal(res.status, 400)
|
||||
assert.match((await res.json()).message, /brand_assets\.logo/)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a successful upload invalidates the cached HTML shell', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.get = async () => null
|
||||
settingsDb.set = async () => {}
|
||||
let invalidated = 0
|
||||
const realInvalidate = htmlShell.invalidate
|
||||
htmlShell.invalidate = () => {
|
||||
invalidated += 1
|
||||
}
|
||||
const app = await startSettingsApp()
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
|
||||
method: 'POST',
|
||||
body: form(PNG),
|
||||
})
|
||||
assert.equal(res.status, 201)
|
||||
assert.equal(invalidated, 1, 'the favicon an admin just uploaded must not wait for the TTL')
|
||||
} finally {
|
||||
htmlShell.invalidate = realInvalidate
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
229
server/test/htmlShell.test.js
Normal file
229
server/test/htmlShell.test.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// Point the DB at a closed port before the pool is built; the settings read is
|
||||
// monkeypatched in every test that reaches it, so no query runs.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
// A brand URL, so the og:image absolutization of an uploaded path is exercised
|
||||
// rather than being dead code in the test environment.
|
||||
process.env.BRAND_URL = process.env.BRAND_URL || 'https://shard.example'
|
||||
// BRAND_LOGO defaults to empty (no logo image rendered), which would make the
|
||||
// "og:image still comes from env" assertions below pass vacuously.
|
||||
process.env.BRAND_LOGO = process.env.BRAND_LOGO || '/brand/logo.png'
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// The cached, settings-aware HTML shell (docs/website/THEMING_AND_NAV.md §4.3).
|
||||
// Three properties are load-bearing enough to lock here: that an untouched
|
||||
// instance gets byte-for-byte the shell it got before this feature existed, that
|
||||
// a DB fault still serves a page, and that the steady state is one cached string
|
||||
// rather than a settings read per page view.
|
||||
const htmlShell = require('../src/utils/htmlShell')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const brand = require('../src/config/brand')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originalGetShellBrand = settings.getShellBrand
|
||||
afterEach(() => {
|
||||
settings.getShellBrand = originalGetShellBrand
|
||||
})
|
||||
|
||||
// A stand-in for the built client/dist/index.html: the two tags the shell
|
||||
// rewrites plus the stylesheet link the theme block has to follow.
|
||||
const TEMPLATE = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite App</title>
|
||||
<meta name="description" content="placeholder" />
|
||||
<link rel="stylesheet" href="/assets/index-abc123.css" />
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>`
|
||||
|
||||
// The shell app.js served BEFORE this phase, reproduced verbatim. The point of
|
||||
// the test is that this string and the new renderer's output are identical for
|
||||
// an instance with no brand_assets and no theme_visual row (§9), so it is copied
|
||||
// rather than imported.
|
||||
function legacyRenderIndexHtml(html) {
|
||||
const htmlEscape = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// ── The byte-identical guarantee (§9) ─────────────────────────────────
|
||||
|
||||
test('with no overrides the shell is byte-identical to the pre-feature one', () => {
|
||||
assert.equal(htmlShell.render(TEMPLATE, {}), legacyRenderIndexHtml(TEMPLATE))
|
||||
})
|
||||
|
||||
test('an empty theme and empty assets are the same as no overrides at all', () => {
|
||||
// A row that parsed to nothing usable resolves to null/undefined rather than
|
||||
// to an empty block, or "reset" would leave a `<style>:root{}` behind forever.
|
||||
assert.equal(htmlShell.render(TEMPLATE, { theme: null }), legacyRenderIndexHtml(TEMPLATE))
|
||||
assert.equal(htmlShell.render(TEMPLATE, { theme: {} }), legacyRenderIndexHtml(TEMPLATE))
|
||||
})
|
||||
|
||||
// ── Brand assets ──────────────────────────────────────────────────────
|
||||
|
||||
test('an uploaded favicon replaces the env one and touches nothing else', () => {
|
||||
const html = htmlShell.render(TEMPLATE, { favicon: '/uploads/1-a.png' })
|
||||
assert.match(html, /<link rel="icon" href="\/uploads\/1-a\.png" \/>/)
|
||||
assert.ok(!html.includes(`href="${brand.favicon}"`), 'the env favicon is gone')
|
||||
// §9: setting only the favicon changes the favicon only.
|
||||
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
|
||||
assert.equal(ogImage ? ogImage[1] : '', brand.logo, 'og:image still resolves from env')
|
||||
})
|
||||
|
||||
test('an uploaded logo becomes og:image, absolutized against BRAND_URL', () => {
|
||||
const html = htmlShell.render(TEMPLATE, { logo: '/uploads/2-b.png' })
|
||||
assert.match(html, /<meta property="og:image" content="https:\/\/shard\.example\/uploads\/2-b\.png" \/>/)
|
||||
})
|
||||
|
||||
test('an env logo is passed through untouched even when relative', () => {
|
||||
// The shell an instance gets today is the operator's choice; only an uploaded
|
||||
// path — which is always relative and is read off-site by scrapers — is made
|
||||
// absolute. Anything else would break the byte-identical guarantee above.
|
||||
const html = htmlShell.render(TEMPLATE, {})
|
||||
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
|
||||
assert.equal(ogImage ? ogImage[1] : '', brand.logo)
|
||||
})
|
||||
|
||||
// ── The theme boot block (removes the first-paint flash) ───────────────
|
||||
|
||||
test('a resolved theme is emitted as a :root block after the stylesheet', () => {
|
||||
const html = htmlShell.render(TEMPLATE, { theme: { '--accent': '#123456', '--bg': '#0b0f14' } })
|
||||
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456;--bg:#0b0f14\}<\/style>/)
|
||||
// Custom properties are equal-specificity, so the later block wins: it must
|
||||
// come after the built stylesheet or a themed instance would paint :root.
|
||||
assert.ok(
|
||||
html.indexOf('theme-boot') > html.indexOf('/assets/index-abc123.css'),
|
||||
'the theme block follows the stylesheet link',
|
||||
)
|
||||
})
|
||||
|
||||
test('a token that could carry markup is dropped, not escaped into the block', () => {
|
||||
const html = htmlShell.render(TEMPLATE, {
|
||||
theme: { '--accent': '#123456', '--x': '</style><script>alert(1)</script>', 'color': 'red' },
|
||||
})
|
||||
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456\}<\/style>/)
|
||||
assert.ok(!html.includes('alert(1)'), 'no injected markup survives')
|
||||
assert.ok(!html.includes('color:red'), 'a non-custom-property name never reaches the block')
|
||||
})
|
||||
|
||||
// ── Caching, invalidation and the DB-fault fallback ────────────────────
|
||||
|
||||
test('the shell is rendered once and then served from cache', async () => {
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
return { logo: brand.logo, favicon: '/uploads/cached.png', theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
const first = await htmlShell.get()
|
||||
const second = await htmlShell.get()
|
||||
assert.equal(reads, 1, 'a settings read per page view would put the DB on every route')
|
||||
assert.equal(first, second)
|
||||
assert.match(first, /\/uploads\/cached\.png/)
|
||||
})
|
||||
|
||||
test('a burst of requests on a cold cache does one read', async () => {
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
return { logo: brand.logo, favicon: brand.favicon, theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
await Promise.all([htmlShell.get(), htmlShell.get(), htmlShell.get()])
|
||||
assert.equal(reads, 1)
|
||||
})
|
||||
|
||||
test('invalidate() makes the next request re-read', async () => {
|
||||
let favicon = '/uploads/old.png'
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
return { logo: brand.logo, favicon, theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
assert.match(await htmlShell.get(), /old\.png/)
|
||||
favicon = '/uploads/new.png'
|
||||
assert.match(await htmlShell.get(), /old\.png/, 'still cached until told otherwise')
|
||||
htmlShell.invalidate()
|
||||
assert.match(await htmlShell.get(), /new\.png/)
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
|
||||
test('the cache expires on its own, so a second process converges', async () => {
|
||||
let favicon = '/uploads/old.png'
|
||||
settings.getShellBrand = async () => ({ logo: brand.logo, favicon, theme: null })
|
||||
htmlShell.init(TEMPLATE)
|
||||
assert.match(await htmlShell.get(), /old\.png/)
|
||||
// Nothing invalidates here: this is the worker that did NOT handle the write.
|
||||
favicon = '/uploads/new.png'
|
||||
const realNow = Date.now
|
||||
Date.now = () => realNow() + htmlShell.TTL_MS + 1
|
||||
try {
|
||||
assert.match(await htmlShell.get(), /new\.png/)
|
||||
} finally {
|
||||
Date.now = realNow
|
||||
}
|
||||
})
|
||||
|
||||
test('a settings read that throws serves the env-only shell instead of failing', async () => {
|
||||
settings.getShellBrand = async () => {
|
||||
throw new Error('ER_CON_COUNT_ERROR')
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
assert.equal(await htmlShell.get(), legacyRenderIndexHtml(TEMPLATE))
|
||||
})
|
||||
|
||||
test('the fallback is cached too — an outage is not a query per page view', async () => {
|
||||
let reads = 0
|
||||
settings.getShellBrand = async () => {
|
||||
reads += 1
|
||||
throw new Error('down')
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
await htmlShell.get()
|
||||
await htmlShell.get()
|
||||
assert.equal(reads, 1)
|
||||
})
|
||||
|
||||
test('an invalidation during a render is not overwritten by the stale result', async () => {
|
||||
let favicon = '/uploads/old.png'
|
||||
settings.getShellBrand = async () => {
|
||||
const value = favicon
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
return { logo: brand.logo, favicon: value, theme: null }
|
||||
}
|
||||
htmlShell.init(TEMPLATE)
|
||||
const inflight = htmlShell.get() // reads 'old'
|
||||
favicon = '/uploads/new.png'
|
||||
htmlShell.invalidate() // the write lands mid-render
|
||||
await inflight
|
||||
assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached')
|
||||
})
|
||||
Reference in New Issue
Block a user