// 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 , a , 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 }