Files
Module-Rust/server/router/admin/rust.controller.js
wtclaude aea94b5d7a feat(rust): chat titles rank twenty-three conditions, and admins name the categories (PLAN_REDESIGNS §5, D172-D175, D209)
- Schema: fourteen title columns on rust_player_wipe_stats (the two
  best_* distances move by GREATEST, the rest are sums), rust_weapon_kills
  (kills by weapon prefab name) and rust_title_categories (the admin's
  title per category). purge.sql drops the two tables.
- Ingest: player.tally's new fields, in one statement per frame, none
  for an older plugin's frame.
- Titles: 23 categories plus playtime, each naming where it is read
  from (a sum, a MAX, npc_kills - animal_kills in signed arithmetic, or
  a named list). The bow/melee/blade/revolver/wood/ore/plants lists are
  one file, applied when a title is read. "NPC kills" ranks human NPCs
  only (D209).
- A rule's text may be empty, meaning the category's title: the rule's
  own, then the admin's, then the default. Typed-only-markup is still
  refused. A rename forgets every server's cached answer.
- Admin API: GET /admin/rust/title-categories and
  PUT /admin/rust/title-categories/:stat (empty text resets); the server
  list returns them too. Swagger fragment and routes.manifest.json
  regenerated (63 routes, against the pinned core).
- Screen: every category in the rule form, the category's title as a
  placeholder, and a Category titles section with Save and Reset.

The weapon lists are unverified until the rig probe runs.

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

375 lines
14 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, categories] = await Promise.all([
servers.listForAdmin(),
titlesModel.settingsByServer(),
titlesModel.categories(),
])
// Phase 17: each server's chat titles, and what the last push of them found.
// PLAN_REDESIGNS §5.5: the category titles too, since a rule with no text of
// its own shows its category's, and the list says which that is.
res.json({
servers: rows.map((row) => ({
...row,
titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] },
titlePush: titleSync.lastPush(row.id),
})),
titleCategories: categories,
})
} 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' })
}
}
/** The title categories (D175): each one's default, the admin's own, and what shows. */
async function getTitleCategories(req, res) {
try {
return res.json({ categories: await titlesModel.categories() })
} catch (err) {
log.error('failed to read the title categories', { error: err.message })
return res.status(500).json({ message: 'Failed to read the title categories' })
}
}
/**
* Rename one category for the whole site (D175), or reset it with an empty
* text. Every rule of that category without its own text shows the new title
* on every server from the next push.
*/
async function putTitleCategory(req, res) {
const { stat } = req.params
try {
const checked = titles.validateCategory(stat, req.body.text)
if (!checked.ok) {
const missing = !titles.CATEGORY_IDS.includes(stat)
return res.status(missing ? 404 : 400).json({ message: checked.error, errors: [checked.error] })
}
await titlesModel.setCategory(stat, checked.text, req.user ? req.user.id : null)
await core.activity.log({
req,
action: checked.text ? 'rust.titles.category.set' : 'rust.titles.category.reset',
detail: { stat, text: checked.text || null },
})
return res.json({ categories: await titlesModel.categories() })
} catch (err) {
log.error('failed to save a title category', { stat, error: err.message })
return res.status(500).json({ message: 'Failed to save the category title' })
}
}
/**
* 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,
getTitleCategories,
putTitleCategory,
integrations,
getVoice,
putVoice,
}