Four event verbs and the announce leg, per PLAN.md §29: - rust.participation.open / .collect: the plugin counts who takes part (seconds, kills or both, in a zone this run opened or the whole server) and collect files them as the run's participants, keyed by Steam id. - rust.kit.entitle: the five recipient modes (D101), rows in the new rust_perm_run_grants (D84) unioned into the permission push, one extra use of the kit per reward as site-held credits on perm.sync (D103), and the rust.kit.entitled notice deferred from phase 10 (D64). - rust.announce: one server or every server (D105). - rust.chat announce leg, speaking only on servers whose new news switch is on (D104) - a card on Admin -> Rust visibility (D106). Budgets rust.grants and rust.announcements; the kit source and four fixed-choice sources (core has no enum param type). rust_perm_run_grants carries core's idempotency key so a revert of a lost answer can find its rows. Protocol 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
197 lines
8.0 KiB
JavaScript
197 lines
8.0 KiB
JavaScript
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
|
//
|
|
// Core's own backend is layered `router → controller → model → db`, with models
|
|
// in pairs: a `.db.js` holding the SQL and a `.model.js` holding the logic that
|
|
// calls it. The split earns its keep here for the same reason it does in core —
|
|
// the file with the queries in it has no branching to test, and the file with the
|
|
// branching in it has no database to stand up.
|
|
//
|
|
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always.
|
|
|
|
const core = require('../../core')
|
|
|
|
const SERVERS = 'rust_servers'
|
|
const STATE = 'rust_server_state'
|
|
|
|
/**
|
|
* Every configured server, in the operator's own order.
|
|
*
|
|
* **The encrypted token comes back on this read and is never returned to a
|
|
* client.** Decryption happens in the model, one layer up; this file's job is to
|
|
* fetch a column, not to decide who may see it.
|
|
*/
|
|
async function listServers({ enabledOnly = false } = {}) {
|
|
return core.query(
|
|
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
|
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
|
|
created_at AS createdAt, updated_at AS updatedAt
|
|
FROM ${SERVERS}
|
|
${enabledOnly ? 'WHERE enabled = 1' : ''}
|
|
ORDER BY sort_order ASC, id ASC`,
|
|
)
|
|
}
|
|
|
|
async function getServer(id) {
|
|
const rows = await core.query(
|
|
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
|
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
|
|
FROM ${SERVERS}
|
|
WHERE id = ?`,
|
|
[id],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
/**
|
|
* Create or replace a server row.
|
|
*
|
|
* **`sidecar_token_enc` is only written when a value is supplied.** An admin form
|
|
* that shows a blank token field — which is the only thing it can show, since the
|
|
* token is write-only — posts an empty string on every save that did not intend
|
|
* to change it. Writing that through would erase the credential every time an
|
|
* operator renamed a server, and the failure would present as the bridge going
|
|
* down for no reason an hour after an unrelated edit.
|
|
*/
|
|
async function upsertServer({ id, name, sidecarBaseUrl, sidecarTokenEnc, protocol, enabled, sortOrder }) {
|
|
const setToken = sidecarTokenEnc !== null && sidecarTokenEnc !== undefined
|
|
|
|
await core.query(
|
|
`INSERT INTO ${SERVERS}
|
|
(id, name, sidecar_base_url, sidecar_token_enc, protocol, enabled, sort_order, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON DUPLICATE KEY UPDATE
|
|
name = VALUES(name),
|
|
sidecar_base_url = VALUES(sidecar_base_url),
|
|
${setToken ? 'sidecar_token_enc = VALUES(sidecar_token_enc),' : ''}
|
|
protocol = VALUES(protocol),
|
|
enabled = VALUES(enabled),
|
|
sort_order = VALUES(sort_order),
|
|
updated_at = CURRENT_TIMESTAMP`,
|
|
[id, name, sidecarBaseUrl, setToken ? sidecarTokenEnc : null, protocol, enabled ? 1 : 0, sortOrder],
|
|
)
|
|
}
|
|
|
|
async function deleteServer(id) {
|
|
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
|
|
}
|
|
|
|
/** The last thing each server said about itself, keyed by server id. */
|
|
async function listState() {
|
|
return core.query(
|
|
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
|
|
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
|
|
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
|
|
last_seen_at AS lastSeenAt, updated_at AS updatedAt
|
|
FROM ${STATE}`,
|
|
)
|
|
}
|
|
|
|
/** One server's observed state, or `null`. The single-row twin of `listState`. */
|
|
async function getState(serverId) {
|
|
const rows = await core.query(
|
|
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
|
|
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
|
|
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
|
|
last_seen_at AS lastSeenAt, updated_at AS updatedAt
|
|
FROM ${STATE}
|
|
WHERE server_id = ?`,
|
|
[serverId],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
/**
|
|
* Mark a server unreachable **without forgetting what it last said**.
|
|
*
|
|
* `putState` replaces the row whole, which is right when a sidecar answered: the
|
|
* frame it answered with is the complete truth about that server. It is wrong
|
|
* when nothing answered. A refresh that cannot reach a sidecar knows exactly one
|
|
* new fact — that it could not reach it — and writing the whole row from that
|
|
* one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL.
|
|
*
|
|
* The site's whole premise is that it renders the last thing each server said
|
|
* while every server is off. A row blanked the first time a game host reboots
|
|
* cannot do that: the page loses the map, the size, the seed and the wipe, and
|
|
* what it shows is not "offline, here is what we know" but "offline, and we have
|
|
* never heard of it". It is invisible in every test that stubs a reachable
|
|
* sidecar, and it shows up as a page that was complete an hour ago.
|
|
*
|
|
* So: three columns move, and the description stays where it is.
|
|
*/
|
|
async function markUnreachable(serverId, reachable = false) {
|
|
await core.query(
|
|
`INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at)
|
|
VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP)
|
|
ON DUPLICATE KEY UPDATE
|
|
reachable = VALUES(reachable),
|
|
online = 0,
|
|
players = 0,
|
|
updated_at = CURRENT_TIMESTAMP`,
|
|
[serverId, reachable ? 1 : 0],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Replace one server's observed state.
|
|
*
|
|
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
|
|
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
|
|
* value, so an update writing the same numbers back — exactly what a quiet
|
|
* server looks like — leaves the timestamp where it was. The row would then
|
|
* cross the freshness window and the page would report the server offline while
|
|
* it was up and reporting normally. That is invisible to every test and shows up
|
|
* as a page that was right when you looked at it and wrong an hour later.
|
|
*/
|
|
async function putState(state) {
|
|
await core.query(
|
|
`INSERT INTO ${STATE}
|
|
(server_id, reachable, online, players, max_players, hostname, level, seed,
|
|
world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, IF(?, CURRENT_TIMESTAMP, NULL), CURRENT_TIMESTAMP)
|
|
ON DUPLICATE KEY UPDATE
|
|
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
|
|
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
|
|
seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id),
|
|
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
|
|
protocol = VALUES(protocol),
|
|
raw = VALUES(raw),
|
|
-- Only a CONNECTED game moves this; an unreachable write leaves it alone,
|
|
-- and so does a board the sidecar kept after the game went away (D68).
|
|
-- That is what lets a page say how long a server has been down rather
|
|
-- than how recently we failed to reach it.
|
|
last_seen_at = IF(?, CURRENT_TIMESTAMP, last_seen_at),
|
|
updated_at = CURRENT_TIMESTAMP`,
|
|
[
|
|
state.serverId,
|
|
state.reachable ? 1 : 0,
|
|
state.online ? 1 : 0,
|
|
state.players || 0,
|
|
state.maxPlayers || 0,
|
|
state.hostname || null,
|
|
state.level || null,
|
|
state.seed === undefined ? null : state.seed,
|
|
state.worldSize === undefined ? null : state.worldSize,
|
|
state.bootId || null,
|
|
state.saveCreatedAt || null,
|
|
state.wipeId || null,
|
|
state.protocol === undefined ? null : state.protocol,
|
|
state.raw ? JSON.stringify(state.raw) : null,
|
|
state.seen === false ? 0 : 1,
|
|
state.seen === false ? 0 : 1,
|
|
],
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
SERVERS,
|
|
STATE,
|
|
listServers,
|
|
getServer,
|
|
upsertServer,
|
|
deleteServer,
|
|
listState,
|
|
getState,
|
|
markUnreachable,
|
|
putState,
|
|
}
|