Files
Module-Rust/server/router/admin/rust.controller.js
wtclaude 0670341198 feat(rust): slash commands and the next wipe, server half (phase 16)
Five read-only commands registered with api.registerSlashCommands:
/status, /wipe, /top, /online and /clan (D126). Every refusal is private,
and any answer narrower than public (online names, a clan roster) goes
to the caller alone (D127). No command asks a sidecar.

The next wipe (D128, D130): six nullable columns on rust_servers, a pure
nextWipe(row, now) with the zone arithmetic through Intl, computed on
every read. The public server shape gains nextWipe; the admin shape
gains the stored schedule; PUT /admin/rust/servers/:id takes the six
fields and writes them only when wipeRule is present.

server/commands joins ci/bundle.json, which checkBundle caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 13:29:59 -05:00

222 lines
9.0 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 log = core.logger('admin')
async function listServers(req, res) {
try {
res.json({ servers: await servers.listForAdmin() })
} 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' })
}
}
module.exports = { scheduleFrom, listServers, putServer, deleteServer, testServer, fetchMap, renderMap }