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