// ── 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, }