feat(brand): BRAND_* env scheme — instance branding without a rebuild
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m20s

Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env
scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the
first tenant that sets these vars rather than a special case in the code.

Architecture (chosen because the app ships as a prebuilt image):
- server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot,
  with Runic Gateway defaults.
- Text/colors reach the SPA at RUNTIME through the existing public settings
  API (settings.model.getPublic -> SiteContext), so no client rebuild. The
  admin-editable site title + contact email still override BRAND_NAME/email.
- SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime.
- Express templates the built index.html <title>/description/OG/favicon at
  serve time from BRAND_* (renderIndexHtml in app.js).
- Server-side consumers read brand directly: emails, TOTP issuer, API docs,
  boot logs, HTML error page. Bot uses it for embed color + logs.

Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount
(BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back
to a built-in image when unset.

Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*)
and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/
rg_token). Production keeps its real values by pinning them in .env — see
.env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity
(proof the substitution works). Changing a deployed COOKIE_NAME invalidates
existing sessions, so UOMysticmoon pins uomm_token.

Verified: 193 server tests pass, client builds, app.js loads + templates the
built index.html, brand transform injects title/description/OG/favicon.
This commit is contained in:
2026-07-18 02:20:04 -05:00
parent 1bb9e3c3c3
commit 7a08546da6
49 changed files with 389 additions and 119 deletions

View File

@@ -10,6 +10,7 @@ require('dotenv').config()
const swaggerUi = require('swagger-ui-express')
const apiRouter = require('./router/api.router')
const brand = require('./config/brand')
const createLogger = require('./utils/logger')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
@@ -64,8 +65,41 @@ const SERVER_ROOT = path.join(__dirname, '..')
const REPO_ROOT = path.join(SERVER_ROOT, '..')
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads')
const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist')
const BRAND_DIR = process.env.BRAND_DIR || path.join(REPO_ROOT, 'brand')
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
// Escape user/brand text for safe interpolation into the HTML shell.
const htmlEscape = (s) =>
String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
@@ -89,7 +123,7 @@ try {
res.json(swaggerSpec)
})
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customSiteTitle: 'UOMysticmoon API docs',
customSiteTitle: `${brand.name} API docs`,
swaggerOptions: { persistAuthorization: true },
}))
} catch (err) {
@@ -112,15 +146,30 @@ app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
// ── Client SPA ────────────────────────────────────────────────────────
// Serve the built React app if present; otherwise show a placeholder so the
// server is usable API-only before the frontend phase.
// Brand assets (logo/hero/favicon) from a mounted directory, used when BRAND_*
// paths point at /brand/*. Optional — the defaults live under the SPA's /assets,
// so this only matters for a custom mount.
if (fs.existsSync(BRAND_DIR)) {
app.use(
'/brand',
express.static(BRAND_DIR, {
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
}),
)
}
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
app.use(express.static(CLIENT_DIST))
app.get('*', (req, res) => res.sendFile(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'))
app.use(express.static(CLIENT_DIST, { index: false }))
app.get('*', (req, res) => res.type('html').send(indexHtml))
} else {
app.get('*', (req, res) =>
res
.type('html')
.send(
'<h1>UOMysticmoon API</h1><p>The web client has not been built yet. ' +
`<h1>${htmlEscape(brand.name)} API</h1><p>The web client has not been built yet. ` +
'The API is available under <code>/api/v1</code>.</p>',
),
)

View File

@@ -14,7 +14,7 @@ require('dotenv').config()
const log = require('../utils/logger')('auth')
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
const COOKIE_NAME = process.env.COOKIE_NAME || 'rg_token'
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'

View File

@@ -0,0 +1,46 @@
// ── Branding (BRAND_*) ─────────────────────────────────────────────────────
//
// Single source of instance branding. Reads BRAND_* env vars once at startup,
// with Runic Gateway defaults, so any instance substitutes its own identity
// without a rebuild (the app ships as one prebuilt image).
//
// How it reaches the UI:
// • Text + colors + asset paths are surfaced to the SPA through the public
// settings API (settings.model.getPublic → SiteContext). The two fields the
// admin can edit (site title, contact email) override these defaults.
// • The static index.html shell (title/description/OG/favicon) is templated by
// Express at serve time (see src/app.js).
// • Server-side consumers (emails, TOTP issuer, API docs) read this directly.
//
// Image assets are delivered from the /brand mount (BRAND_LOGO/HERO/FAVICON), or
// any absolute URL. Runic Gateway ships neutral defaults baked into the image so
// an instance with no BRAND_* set still renders.
require('dotenv').config()
const name = process.env.BRAND_NAME || 'Runic Gateway'
const brand = {
name,
shortName: process.env.BRAND_SHORT_NAME || name,
tagline: process.env.BRAND_TAGLINE || 'an independent private Ultima Online shard',
description:
process.env.BRAND_DESCRIPTION ||
`${name} — an independent private Ultima Online shard. News, screenshots, guides, and community notes.`,
contactEmail: process.env.BRAND_CONTACT_EMAIL || process.env.CONTACT_TO || '',
url: process.env.BRAND_URL || '',
// Visual
accent: process.env.BRAND_ACCENT_COLOR || '#7f99bd',
logo: process.env.BRAND_LOGO || '', // empty → no logo image rendered
hero: process.env.BRAND_HERO || '/assets/img/hero-moon.png',
favicon: process.env.BRAND_FAVICON || '', // empty → no favicon link injected
}
// Discord embeds want an int (0xRRGGBB). Parse the accent hex once; fall back to
// the default accent if it's malformed.
brand.accentInt = (() => {
const hex = String(brand.accent).replace('#', '')
const n = parseInt(hex, 16)
return Number.isNaN(n) ? 0x7f99bd : n
})()
module.exports = brand

View File

@@ -1,4 +1,5 @@
const settingsDb = require('./settings.db')
const brand = require('../../config/brand')
// Keys safe to expose on the public site.
const PUBLIC_KEYS = [
@@ -89,6 +90,21 @@ async function getPublic() {
// 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)
// Instance branding (BRAND_* env defaults). The two admin-editable settings —
// site title and contact email — override the env value when set, so existing
// installs keep their DB-configured name; everything else comes from env.
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: brand.accent,
logo: brand.logo,
hero: brand.hero,
favicon: brand.favicon,
}
return out
}

View File

@@ -15,6 +15,7 @@ const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const brand = require('./config/brand')
const pkg = require('../package.json')
const log = createLogger('server')
@@ -27,12 +28,12 @@ const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
async function start() {
log.info(`starting UOMysticmoon server v${pkg.version}`, {
log.info(`starting ${brand.name} server v${pkg.version}`, {
node: process.version,
env: process.env.NODE_ENV || 'development',
logLevel: process.env.LOG_LEVEL || 'info',
logFile: createLogger.logFilePath || 'disabled (console only)',
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`,
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`,
cookieSecure: process.env.COOKIE_SECURE || 'auto',
email: 'gmail-oauth2 (configured in admin → settings)',
})

View File

@@ -10,7 +10,7 @@ const pool = mariadb.createPool({
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'uomysticmoon',
database: process.env.DB_NAME || 'runic_gateway',
connectionLimit: 5,
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
insertIdAsNumber: true,

View File

@@ -14,6 +14,7 @@ const nodemailer = require('nodemailer')
const emailConfig = require('../model/emailConfig/emailConfig.model')
const authProviders = require('../model/authProviders/authProviders.model')
const settings = require('../model/settings/settings.model')
const brand = require('../config/brand')
const log = require('./logger')('mailer')
// Ready to send only when enabled, connected (has a refresh token), and we know
@@ -76,7 +77,7 @@ async function sendContactMessage({ name, email, message }) {
from: fromHeader(config),
to,
replyTo: email,
subject: `UOMysticmoon contact from ${name || 'a visitor'}`,
subject: `${brand.name} contact from ${name || 'a visitor'}`,
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
@@ -110,7 +111,7 @@ async function sendTest(to) {
await transport.sendMail({
from: fromHeader(config),
to: recipient,
subject: 'UOMysticmoon email test',
subject: `${brand.name} email test`,
text: 'This is a test message confirming Gmail OAuth2 email delivery is working.',
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
@@ -139,9 +140,9 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
await transport.sendMail({
from: fromHeader(config),
to,
subject: 'Your UOMysticmoon invitation',
subject: `Your ${brand.name} invitation`,
text:
`You have been invited${by} to join UOMysticmoon${roleLabel}.\n\n` +
`You have been invited${by} to join ${brand.name}${roleLabel}.\n\n` +
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
})

View File

@@ -9,7 +9,9 @@
const speakeasy = require('speakeasy')
const QRCode = require('qrcode')
const ISSUER = process.env.TOTP_ISSUER || 'UOMysticmoon'
const brand = require('../config/brand')
const ISSUER = process.env.TOTP_ISSUER || brand.name
// Generate a new secret. Returns the base32 secret to persist plus the otpauth
// URL to encode in a QR code.