ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it publishes that verbatim, so the rules page read "My Shard" under a header carrying the real name. That value is the shard saying *unnamed* rather than naming anything, so the site now answers with its own. `settings.getInstanceName()` resolves `site_title || BRAND_NAME` — the same resolution `getPublic().brand.name` already uses, so an install that set only the site title can never show two different names on two pages. Bare `brand.name` would have been wrong for exactly that case. Substituted at INGEST rather than on read: world.ruleset is also broadcast live, and the same object is handed to the SSE fan-out, so a read-time fix would be undone by the next reconnect's frame. Matched case- and padding-insensitively but only as a whole value, so a shard genuinely called "My Shard Reborn" keeps its name. Fixes a second ruleset writer found on the way: uoLinkSocket.backfill() called shardState.setRuleset directly instead of going through the dispatcher as ingestEach does, so the boot/reconnect snapshot silently skipped this normalization. The two arrival orders have to produce the same stored frame. Also renders a placeholder row on an unscored leaderboard — the instance name with an em dash where a score goes, deliberately not shaped like an entry (no medal, no bar) because a placeholder that looked like a real standing would be a fabricated one. Presentation only; the API still sends an empty `top`. Verified live against the shard + sidecar: rules page and leaderboards on web and Android both correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
188 lines
6.9 KiB
JavaScript
188 lines
6.9 KiB
JavaScript
const settingsDb = require('./settings.db')
|
|
const brand = require('../../config/brand')
|
|
|
|
// 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.
|
|
]
|
|
|
|
// 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 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)
|
|
// 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,
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
setMany,
|
|
getAll,
|
|
getPublic,
|
|
getInstanceName,
|
|
PUBLIC_KEYS,
|
|
REGISTRATION_KEY,
|
|
REGISTRATION_MODES,
|
|
getRegistrationMode,
|
|
registrationFlags,
|
|
GAME_SIGNUP_KEY,
|
|
GAME_SIGNUP_MODES,
|
|
getGameSignupMode,
|
|
isGameAccountSignupEnabled,
|
|
MOBILE_APP_LINKS_KEY,
|
|
isMobileAppLinksEnabled,
|
|
}
|