Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
289 lines
12 KiB
JavaScript
289 lines
12 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.
|
|
// The two Team-forum controls (TEAMS.md §5.5.6). The client needs the first to
|
|
// know whether to render the forum panel at all, and the second to decide which
|
|
// composer to show — an upload control that 404s is worse than no control.
|
|
// Neither is sensitive.
|
|
//
|
|
// `teams_forum_uploads_ack` is deliberately NOT here: who accepted a liability
|
|
// notice is operator detail, exactly as `failure_reason` is in MODULE_API.md
|
|
// §2.9. And publishing the mode does not move the DECISION client-side — the
|
|
// server still resolves what renders (§5.5.3); the client is only told which
|
|
// composer to draw.
|
|
'teams_forums_enabled',
|
|
'teams_forum_images',
|
|
]
|
|
|
|
// 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',
|
|
}
|
|
}
|
|
|
|
// Engagement Phase 1b — may an UNVERIFIED address receive opt-in engagement mail?
|
|
// Stored as 'on'/'off'. Seeded by schema.sql ASYMMETRICALLY on purpose: 'on' for a
|
|
// fresh install, 'off' for an upgrade. Turning it on retroactively would silently
|
|
// stop mailing every already-opted-in user on the day the operator upgraded, which
|
|
// is the G22 mistake — a safe default must not be applied backwards to a running
|
|
// system without telling anyone.
|
|
//
|
|
// Nothing CONSUMES this yet: the engine that would honour it arrives in Phase 4
|
|
// and the deliverability rules in Phase 9. It is seeded and editable here because
|
|
// the fresh-vs-upgrade distinction is only knowable at the migration that adds it,
|
|
// and reconstructing "was this install fresh?" later is guesswork.
|
|
const EMAIL_VERIFICATION_KEY = 'email_verification_required'
|
|
|
|
// Fail-safe direction is 'off': an unreadable or missing value must not silently
|
|
// suppress mail an operator believes is going out. The loud failure mode (mail
|
|
// reaching an unverified address) is recoverable; the quiet one is not.
|
|
async function isEmailVerificationRequired() {
|
|
try {
|
|
return String(await settingsDb.get(EMAIL_VERIFICATION_KEY)) === 'on'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
// `gameAccountSignup` was derived here until Phase 3 slice 3. It said whether
|
|
// the site offers GAME-account creation, which is a question about a shard —
|
|
// its own SignupMode has to agree — so it left with the rest of core's UO
|
|
// prose. The `game_account_signup` row is unchanged and module-uo reads it
|
|
// through ctx.settings; the derived flag is on its `/public/shard/features`.
|
|
//
|
|
// 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,
|
|
EMAIL_VERIFICATION_KEY,
|
|
isEmailVerificationRequired,
|
|
registrationFlags,
|
|
MOBILE_APP_LINKS_KEY,
|
|
isMobileAppLinksEnabled,
|
|
}
|