Files
website/server/src/model/settings/settings.model.js
wtclaude 847cfd2d2b feat(theming): brand-asset overrides and a cached, settings-aware HTML shell
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon
overrides on top of the BRAND_* env defaults, delivered through an HTML
shell that is no longer built once at boot.

- utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per
  process, invalidated on a brand_assets/theme_visual write with a 5-minute
  TTL so other workers converge. A settings-read failure renders the
  env-only shell and caches that, so a DB outage is not a failing query per
  page view, and with no rows the output is byte-identical to what app.js
  served before.
- POST /admin/settings/brand-asset/:slot uploads one asset and writes the
  row in the same call, so an upload never leaves an unreferenced file. It
  reuses the shared multer allowlist and only tightens it per slot: favicons
  are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused
  files are unlinked before the response.
- utils/brandAssets.js constrains a stored asset to a same-origin path under
  /uploads, /brand or /assets — these are the only settings values written
  straight into the page as a URL. Strict on write, forgiving on read.
- The shell also carries the resolved theme as a <style id="theme-boot">
  block, removing the first-paint flash phases 3-4 deferred; SiteContext
  drops that block once a successful settings fetch has been applied.
- BrandLogo renders beside the MoonDot on all six shells and renders nothing
  when no logo is set, which is the shipped default.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 20:09:56 -05:00

274 lines
11 KiB
JavaScript

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 = [
'site_mode',
'maintenance_message',
'status_message',
'homepage_teaser',
'contact_email',
'site_title',
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
'nav_public', // public site nav overrides (JSON). §6.4.
]
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
// All five are JSON strings and all five are ABSENT by default — no migration
// seeds them. Absence of the row, not an empty value, is what makes a surface
// fall back to BRAND_* env / the hardcoded theme.css / the hardcoded NAV arrays.
//
// nav_admin and nav_player are deliberately not public: an anonymous visitor has
// no use for either, and the admin nav's labels describe the shape of the admin
// surface. They are read by their owners through GET /api/v1/settings/nav (§4.2).
const THEMING_KEYS = ['theme_visual', 'brand_assets', 'nav_public', 'nav_admin', 'nav_player']
// Keys a reset may delete. An explicit allowlist, not "any key": DELETE on an
// arbitrary key would let a bad request drop site_mode or the uo-link config,
// whose absence means something else entirely. hero_layout_draft is included
// because discarding a draft is the same operation.
const DELETABLE_KEYS = [...THEMING_KEYS, 'hero_layout_draft']
// Player self-registration mode. Stored under the 'player_registration' key.
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
// availability flags from it instead (see below).
const REGISTRATION_KEY = 'player_registration'
const REGISTRATION_MODES = ['disabled', 'password', 'sso', 'both']
// Resolve the registration mode, defaulting to 'disabled' (and coercing any
// unexpected stored value back to 'disabled' so a bad row can't open sign-up).
async function getRegistrationMode() {
const value = await settingsDb.get(REGISTRATION_KEY)
return REGISTRATION_MODES.includes(value) ? value : 'disabled'
}
// Derived, public-safe availability flags for the register page.
function registrationFlags(mode) {
return {
password: mode === 'password' || mode === 'both',
sso: mode === 'sso' || mode === 'both',
}
}
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
// disabled — the site never offers game-account creation (link-only).
// website — the site is the authority (offer creation; pair with the shard in
// website mode + AutoCreateAccounts=false).
// hybrid — either side may create (the site offers creation).
// game — the game server is the authority; the site does NOT offer creation.
// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
// (Bridge.cfg) still has the final say and may 403 a call regardless.
const GAME_SIGNUP_KEY = 'game_account_signup'
const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
const GAME_SIGNUP_OFFER = ['website', 'hybrid']
async function getGameSignupMode() {
const v = await settingsDb.get(GAME_SIGNUP_KEY)
return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
}
async function isGameAccountSignupEnabled() {
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
}
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
// self-origin https://<host>/mobile/callback redirect. Stored as the string
// 'true'/'false'; default off. See docs/android/APP_LINKS.md.
const MOBILE_APP_LINKS_KEY = 'mobile_app_links_enabled'
// Fail-closed: any read error (e.g. DB unavailable) reports "disabled" so a
// transient fault can never open the https redirect path or serve assetlinks.json.
async function isMobileAppLinksEnabled() {
try {
return String(await settingsDb.get(MOBILE_APP_LINKS_KEY)) === 'true'
} catch {
return false
}
}
/**
* This instance's name, resolved exactly as `getPublic().brand.name` resolves it —
* the admin-editable site title wins over BRAND_NAME. Anything that has to *speak*
* the instance's name outside the settings payload must use this rather than
* `brand.name`, or an install that set only the site title gets two different names
* on two different pages.
*
* Never throws: a name is always better than an error, so a DB fault falls back to
* the env value.
*/
async function getInstanceName() {
try {
return (await settingsDb.get('site_title')) || brand.name
} catch {
return brand.name
}
}
async function get(key) {
return settingsDb.get(key)
}
async function set(key, value, updatedBy = null) {
return settingsDb.set(key, value, updatedBy)
}
async function remove(key) {
return settingsDb.remove(key)
}
async function setMany(obj, updatedBy = null) {
for (const [key, value] of Object.entries(obj)) {
await settingsDb.set(key, value, updatedBy)
}
}
async function getAll() {
const rows = await settingsDb.getAll()
return rows.reduce((acc, row) => {
acc[row.key] = row.value
return acc
}, {})
}
async function getPublic() {
const all = await getAll()
const out = PUBLIC_KEYS.reduce((acc, key) => {
if (all[key] !== undefined) acc[key] = all[key]
return acc
}, {})
// Derived registration availability (never the raw mode). Lets the register
// page show/hide the password form and SSO buttons.
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
out.registration = registrationFlags(mode)
// Whether the site offers game-account creation (the shard's own mode still has
// the final say when the call is made). Lets the portal show/hide the form.
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
// The effective CSS custom properties for the admin's theme, or absent when
// no theme_visual row exists (or nothing in it was usable). The SPA writes
// these onto <html>; absence means it writes nothing and theme.css's :root
// stands, which is what keeps an untouched instance byte-for-byte as today.
// Resolution — :root ← preset ← custom — happens here rather than in CSS so
// there is one authority and brand.accent below can report the same value the
// site actually paints. See THEMING_AND_NAV.md §6.
const theme = resolveThemeTokens(all.theme_visual)
if (theme) out.theme = theme
// Uploaded brand-asset overrides (§6.3), 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
// DB-configured name; everything else comes from env.
//
// brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole
// Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot
// colors its embeds from it. Resolving the effective accent here is what lets
// both track admin theming with no client change.
out.brand = {
name: out.site_title || brand.name,
shortName: brand.shortName,
tagline: brand.tagline,
description: brand.description,
contactEmail: out.contact_email || brand.contactEmail,
url: brand.url,
accent: theme?.['--accent'] || brand.accent,
logo: brandAssets.logo || brand.logo,
hero: brandAssets.hero || brand.hero,
favicon: brandAssets.favicon || brand.favicon,
}
// Push-notification relay (M7). The client-facing ntfy base URL the app's
// embedded distributor registers its device topic against; null when push is
// not configured for this shard, in which case the app simply shows push as
// unavailable. The publisher's own NTFY_BASE_URL may be an internal
// compose-network address, so a distinct NTFY_PUBLIC_URL is preferred; failing
// that we use the first NTFY_ALLOWED_ORIGINS entry (a device endpoint must sit
// on an allowed origin anyway). Never NTFY_BASE_URL — it may be internal-only.
out.push = { ntfyUrl: publicNtfyUrl() }
// Whether this shard has opted into Android App Links (M9 follow-up). Lets a
// native client tell whether it may request the https App Link redirect_uri
// before doing so (the server would otherwise reject an unallowlisted one). The
// custom-scheme callback works regardless of this flag.
out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true'
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
// §4.2). Values are returned as stored: raw JSON strings, or null when the
// admin never overrode that nav.
async function getNav() {
const all = await getAll()
return {
nav_admin: all.nav_admin ?? null,
nav_player: all.nav_player ?? null,
}
}
// The client-facing ntfy base URL (no trailing slash), or null when unset.
function publicNtfyUrl() {
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
if (explicit) return explicit.replace(/\/+$/, '')
const firstOrigin = (process.env.NTFY_ALLOWED_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)[0]
return firstOrigin ? firstOrigin.replace(/\/+$/, '') : null
}
module.exports = {
get,
set,
remove,
setMany,
getAll,
getPublic,
getShellBrand,
getNav,
getInstanceName,
PUBLIC_KEYS,
THEMING_KEYS,
DELETABLE_KEYS,
REGISTRATION_KEY,
REGISTRATION_MODES,
getRegistrationMode,
registrationFlags,
GAME_SIGNUP_KEY,
GAME_SIGNUP_MODES,
getGameSignupMode,
isGameAccountSignupEnabled,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,
}