PLAN.md §33, D134-D143. Protocol 12. - Chat titles (D135-D137): per-server rules (stat, top N, text, colour) that rank the current wipe, and a mode (first | all | up to N). Worked out once in model/titles and read three ways: pushed whole to the game by a new titleSync loop (on change, restart or wipe), and on every leaderboard row as `titles`. Admin: PUT /servers/:id/titles. - Group styles (D138, D139): a site group may carry all twelve BetterChat fields (rust_perm_group_chat). They ride perm.sync with `expect` from the pushed ledger, which gains a value column; a field changed in game is a `chat-field` drift row with the game's value, adopted into the style or put back. A withdrawn style is one `chat-group` retirement, never for `default`, cleared from the ledger only once BetterChat removed it. - The voice (D140): one fleet setting naming a styled group; news and rust.announce chat lines carry its format and the plugin says them with no sender. Admin: GET/PUT /voice. - Popups (D141, D142): rust.announce gains `delivery` (still version 1, from rust.options.delivery); each server gains news_delivery beside the news switch; `popup-unavailable` is not retried. - GET /servers/:id/integrations reads, live, which optional mods a server has loaded. README lists BetterChat and PopupNotifications as optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
327 lines
12 KiB
JavaScript
327 lines
12 KiB
JavaScript
// ── Admin · Rust — the handlers ───────────────────────────────────────────
|
|
//
|
|
// The write side of the module. Three things every handler here owes:
|
|
//
|
|
// 1. **Never return the token.** Not in a response, not in an error, not in an
|
|
// activity-log detail. It is accepted, encrypted and forgotten.
|
|
// 2. **Record the change.** `core.activity.log` writes core's own admin audit
|
|
// row. These handlers edit the credential that reaches a game host; "who
|
|
// changed this" has no second place it is recorded.
|
|
// 3. **Answer rather than throw.** An unhandled rejection reaches core's error
|
|
// handler and gets core blamed for a fault in this module.
|
|
|
|
const core = require('../../core')
|
|
|
|
const db = require('../../model/servers/servers.db')
|
|
const mapImages = require('../../mapImages')
|
|
const nextWipe = require('../../model/servers/nextWipe')
|
|
const servers = require('../../model/servers/servers.model')
|
|
const sidecar = require('../../sidecarClient')
|
|
const titleSync = require('../../titleSync')
|
|
const titles = require('../../model/titles/titles')
|
|
const titlesModel = require('../../model/titles/titles.model')
|
|
const voice = require('../../model/permissions/voice')
|
|
|
|
const log = core.logger('admin')
|
|
|
|
async function listServers(req, res) {
|
|
try {
|
|
const [rows, settings] = await Promise.all([servers.listForAdmin(), titlesModel.settingsByServer()])
|
|
|
|
// Phase 17: each server's chat titles, and what the last push of them found.
|
|
res.json({
|
|
servers: rows.map((row) => ({
|
|
...row,
|
|
titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] },
|
|
titlePush: titleSync.lastPush(row.id),
|
|
})),
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to read the server list', { error: err.message })
|
|
res.status(500).json({ message: 'Failed to read the server list' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The wipe schedule a save carries (phase 16, D130), normalised for storage — or
|
|
* `null` when the body carries none, which leaves the stored one alone.
|
|
*
|
|
* Only the fields the rule reads are kept: a weekly rule's day left behind after
|
|
* the operator switched to `forced` would be a value nothing reads and the form
|
|
* would show back to them as if it meant something.
|
|
*/
|
|
function scheduleFrom(body) {
|
|
if (body.wipeRule === undefined) return null
|
|
const rule = body.wipeRule
|
|
const weekly = rule === 'weekly' || rule === 'biweekly'
|
|
const blank = (v) => v === undefined || v === null || v === ''
|
|
return {
|
|
wipeRule: rule,
|
|
wipeDay: weekly ? Number(body.wipeDay) : null,
|
|
wipeTime: weekly ? body.wipeTime : null,
|
|
wipeTz: weekly ? body.wipeTz : null,
|
|
wipeAnchor: rule === 'biweekly' ? body.wipeAnchor : null,
|
|
wipeOnceAt: blank(body.wipeOnceAt) ? null : new Date(body.wipeOnceAt),
|
|
}
|
|
}
|
|
|
|
async function putServer(req, res) {
|
|
const { id } = req.params
|
|
const { name, sidecarBaseUrl, sidecarToken, protocol, enabled, sortOrder } = req.body
|
|
|
|
try {
|
|
const existing = await db.getServer(id)
|
|
|
|
// A NEW server with no token is a row that can never reach its sidecar, and
|
|
// the operator will read the resulting "unreachable" as a network problem.
|
|
// Refusing it up front costs one round trip and saves that hunt. An EXISTING
|
|
// row is a different case: omitting the token is how you say "leave it".
|
|
if (!existing && !sidecarToken) {
|
|
return res.status(400).json({ message: 'A new server needs its sidecar token' })
|
|
}
|
|
|
|
// Checked whole before anything is written, so a bad schedule saves nothing
|
|
// rather than half a row. The sentences go back as they are: the form shows
|
|
// them beside the fields.
|
|
const schedule = scheduleFrom(req.body)
|
|
const problems = schedule ? nextWipe.validateSchedule(req.body) : []
|
|
if (problems.length) return res.status(400).json({ message: problems.join(' '), errors: problems })
|
|
|
|
await db.upsertServer({
|
|
id,
|
|
name,
|
|
sidecarBaseUrl,
|
|
// `encryptToken` returns null for an empty value, and `upsertServer` reads
|
|
// null as "do not write this column". The two halves of that rule are in
|
|
// different files on purpose: the model decides what a blank means, the SQL
|
|
// decides what null does, and neither has to know the other's reason.
|
|
sidecarTokenEnc: servers.encryptToken(sidecarToken),
|
|
protocol: protocol === undefined ? sidecar.PROTOCOL_VERSION : protocol,
|
|
enabled: enabled === undefined ? true : enabled,
|
|
sortOrder: sortOrder === undefined ? 0 : sortOrder,
|
|
})
|
|
if (schedule) await db.setSchedule(id, schedule)
|
|
|
|
await core.activity.log({
|
|
req,
|
|
action: 'rust.server.save',
|
|
detail: {
|
|
server: id,
|
|
created: !existing,
|
|
sidecarBaseUrl,
|
|
// Whether the credential was rotated, never the credential.
|
|
tokenChanged: Boolean(sidecarToken),
|
|
// The schedule as written, when the save carried one. Nothing in it is a
|
|
// secret, and "who moved the wipe" is a question players will ask.
|
|
...(schedule ? { schedule: { ...schedule, wipeOnceAt: schedule.wipeOnceAt ? schedule.wipeOnceAt.toISOString() : null } } : {}),
|
|
},
|
|
})
|
|
|
|
return res.status(204).end()
|
|
} catch (err) {
|
|
log.error('failed to save a server', { server: id, error: err.message })
|
|
return res.status(500).json({ message: 'Failed to save the server' })
|
|
}
|
|
}
|
|
|
|
async function deleteServer(req, res) {
|
|
const { id } = req.params
|
|
|
|
try {
|
|
const existing = await db.getServer(id)
|
|
if (!existing) return res.status(404).json({ message: 'No such server' })
|
|
|
|
await db.deleteServer(id)
|
|
await core.activity.log({ req, action: 'rust.server.delete', detail: { server: id } })
|
|
|
|
return res.status(204).end()
|
|
} catch (err) {
|
|
log.error('failed to delete a server', { server: id, error: err.message })
|
|
return res.status(500).json({ message: 'Failed to delete the server' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Probe one sidecar and report what came back.
|
|
*
|
|
* This is the route that tells a wrong URL from a wrong token from a mismatched
|
|
* protocol, and that distinction is the whole reason it exists: all three present
|
|
* to an operator as "the site says my server is offline", and each has a
|
|
* different fix. The status string from `sidecarClient` is carried through
|
|
* verbatim so the panel can say which.
|
|
*/
|
|
async function testServer(req, res) {
|
|
const { id } = req.params
|
|
|
|
try {
|
|
const row = await db.getServer(id)
|
|
if (!row) return res.status(404).json({ message: 'No such server' })
|
|
|
|
const result = await sidecar.health(servers.withToken(row))
|
|
|
|
await core.activity.log({
|
|
req,
|
|
action: 'rust.server.test',
|
|
detail: { server: id, ok: result.ok, status: result.status },
|
|
})
|
|
|
|
return res.json({
|
|
ok: result.ok,
|
|
status: result.status,
|
|
// `data` is the sidecar's own health document on success and the mismatch
|
|
// detail on a 409. Both are safe to show: neither carries a credential.
|
|
sidecar: result.data || null,
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to probe a sidecar', { server: id, error: err.message })
|
|
return res.status(500).json({ message: 'Failed to probe the sidecar' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch one server's map picture again (D110's admin button). Runs the same
|
|
* fetch the board poll triggers, forced — the stored hash is not trusted to be
|
|
* current — and answers what it did, because an operator pressing this is
|
|
* asking a question: is the picture this server has the right one?
|
|
*/
|
|
async function fetchMap(req, res) {
|
|
const { id } = req.params
|
|
|
|
try {
|
|
const server = await servers.getForCalling(id)
|
|
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
|
|
|
|
const result = await mapImages.run(server, null, { force: true })
|
|
await core.activity.log({ req, action: 'rust.map.fetch', detail: { server: id, outcome: result.outcome } })
|
|
|
|
if (result.outcome === 'busy') return res.status(409).json({ message: 'A fetch is already running for this server.' })
|
|
return res.status(result.ok ? 200 : 502).json({ ok: result.ok, outcome: result.outcome, message: result.detail || null })
|
|
} catch (err) {
|
|
log.error('failed to fetch a map', { server: id, error: err.message })
|
|
return res.status(500).json({ message: 'Failed to fetch the map' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ask a server with no picture to draw one (D109). **This stalls that game**
|
|
* for seconds — the card says how many before the button is pressed — and it is
|
|
* refused by the plugin when a picture already exists. Answered as soon as the
|
|
* game accepts; the picture is fetched when the render finishes.
|
|
*/
|
|
async function renderMap(req, res) {
|
|
const { id } = req.params
|
|
|
|
try {
|
|
const server = await servers.getForCalling(id)
|
|
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
|
|
|
|
const actor = req.user && (req.user.username || req.user.id)
|
|
const result = await mapImages.render(server, actor != null ? String(actor) : null)
|
|
await core.activity.log({
|
|
req,
|
|
action: 'rust.map.render',
|
|
detail: { server: id, accepted: result.ok, ...(result.ok ? {} : { reason: result.reason || null }) },
|
|
})
|
|
|
|
if (!result.ok) return res.status(result.status || 502).json({ message: result.message })
|
|
return res.status(202).json({ accepted: true, stallSeconds: result.stallSeconds })
|
|
} catch (err) {
|
|
log.error('failed to ask for a render', { server: id, error: err.message })
|
|
return res.status(500).json({ message: 'Failed to ask the server to draw its map' })
|
|
}
|
|
}
|
|
|
|
// ── The optional mods (phase 17) ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Replace one server's chat titles: the rules, the mode and N (D135, D136).
|
|
* Validated whole, so a bad rule saves nothing. The titles reach the game on
|
|
* the push loop's next tick, and the web the next time it reads.
|
|
*/
|
|
async function putTitles(req, res) {
|
|
const { id } = req.params
|
|
|
|
try {
|
|
const existing = await db.getServer(id)
|
|
if (!existing) return res.status(404).json({ message: 'No such server' })
|
|
|
|
const checked = titles.validateSettings(req.body)
|
|
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
|
|
|
|
await titlesModel.saveSettings(id, checked.value)
|
|
await core.activity.log({
|
|
req,
|
|
action: 'rust.titles.save',
|
|
detail: { server: id, mode: checked.value.mode, max: checked.value.max, rules: checked.value.rules },
|
|
})
|
|
|
|
return res.json({ titles: await titlesModel.settingsFor(id) })
|
|
} catch (err) {
|
|
log.error('failed to save chat titles', { server: id, error: err.message })
|
|
return res.status(500).json({ message: 'Failed to save the chat titles' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which optional mods one server has loaded right now, read live from the game
|
|
* (`integrations` on `server.status`). Live, because an operator installs
|
|
* BetterChat and then looks here, and the board the site keeps is from the last
|
|
* time the plugin connected.
|
|
*/
|
|
async function integrations(req, res) {
|
|
const { id } = req.params
|
|
|
|
try {
|
|
const server = await servers.getForCalling(id)
|
|
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
|
|
|
|
const result = await sidecar.liveStatus(server)
|
|
const data = result.ok && result.data ? result.data : null
|
|
|
|
return res.json({
|
|
ok: Boolean(data),
|
|
status: result.status,
|
|
integrations: data && data.integrations ? data.integrations : null,
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to read integrations', { server: id, error: err.message })
|
|
return res.status(500).json({ message: 'Failed to ask the server what it has loaded' })
|
|
}
|
|
}
|
|
|
|
async function getVoice(req, res) {
|
|
try {
|
|
res.json(await voice.describe())
|
|
} catch (err) {
|
|
log.error('failed to read the voice', { error: err.message })
|
|
res.status(500).json({ message: 'Failed to read the announcement voice' })
|
|
}
|
|
}
|
|
|
|
async function putVoice(req, res) {
|
|
try {
|
|
const result = await voice.choose(req.body.group || '', req.user ? req.user.id : null)
|
|
if (!result.ok) return res.status(400).json({ message: result.message })
|
|
|
|
await core.activity.log({ req, action: 'rust.voice.save', detail: { group: result.voice || null } })
|
|
return res.json(await voice.describe())
|
|
} catch (err) {
|
|
log.error('failed to save the voice', { error: err.message })
|
|
return res.status(500).json({ message: 'Failed to save the announcement voice' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
scheduleFrom,
|
|
listServers,
|
|
putServer,
|
|
deleteServer,
|
|
testServer,
|
|
fetchMap,
|
|
renderMap,
|
|
putTitles,
|
|
integrations,
|
|
getVoice,
|
|
putVoice,
|
|
}
|