USER_SAFETY.md step 3, the module half of report, block and hide. - server/safety.js registers the rust.chat report target (snapshot for one reader, null when they could not have seen the line; staff's hide and unhide) and the steam game identity (ownersOf from rust_account_links). - The events feed filters chat per reader, in the query: lines from a blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the lines they hid. Signed-in readers get canBlock on each chat line (false for their own and for staff-linked players). Lines staff hid for everyone are left out for everyone but staff, who get them with hidden: true. - rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS). - POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged. - The feed's chat lines get a menu: Report, Hide for me, Block player, and for staff Hide for everyone / Show to everyone again. - coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge; routes.manifest.json and swagger-fragment.json regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
453 lines
18 KiB
JavaScript
453 lines
18 KiB
JavaScript
// ── Public · Rust — the handlers ──────────────────────────────────────────
|
||
//
|
||
// Thin on purpose: read the request, call a model, answer. Everything worth
|
||
// testing is in the model, which needs no express and no database to test.
|
||
//
|
||
// **A handler must not throw past express.** Core mounts this router inside its
|
||
// own tier router, so an unhandled rejection here reaches core's error handler
|
||
// and answers 500 — survivable, but it means an operator sees core blamed for a
|
||
// fault in this module. Catch, log through `core.logger` (so the line carries the
|
||
// module id), and answer something honest.
|
||
|
||
const core = require('../../core')
|
||
|
||
const clans = require('../../model/clans/clans.model')
|
||
const dangerousTreasures = require('../../dangerousTreasures')
|
||
const guardedCrate = require('../../guardedCrate')
|
||
const raidableBases = require('../../raidableBases')
|
||
const safety = require('../../safety')
|
||
const economics = require('../../model/economics/economics.model')
|
||
const events = require('../../model/events/events.model')
|
||
const map = require('../../model/map/map.model')
|
||
const mapDb = require('../../model/map/map.db')
|
||
const mapLive = require('../../mapLive')
|
||
const npcs = require('../../model/npcs/npcs.model')
|
||
const servers = require('../../model/servers/servers.model')
|
||
const serversDb = require('../../model/servers/servers.db')
|
||
const titles = require('../../model/titles/titles.model')
|
||
const visibility = require('../../model/visibility/visibility.model')
|
||
|
||
const log = core.logger('public')
|
||
|
||
/**
|
||
* Marks a response as depending on who asked.
|
||
*
|
||
* Three routes below answer differently for a moderator and for a stranger, and
|
||
* a shared cache in front of the site that stored the moderator's answer would
|
||
* hand the roll call to the next anonymous visitor. `private` keeps it out of
|
||
* every cache but the viewer's own; `Vary` says why, for any cache that reads it.
|
||
*/
|
||
function perViewer(res) {
|
||
res.set('Cache-Control', 'private, no-store')
|
||
res.vary('Cookie')
|
||
res.vary('Authorization')
|
||
}
|
||
|
||
async function listServers(req, res) {
|
||
try {
|
||
res.json({ servers: await servers.listPublic() })
|
||
} catch (err) {
|
||
log.error('failed to read the server list', { error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the server list' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One server, or a 404.
|
||
*
|
||
* **The 404 is the feature.** Everything else under `/servers/:id` answers an
|
||
* empty list for a server that does not exist — an unknown id has no events, no
|
||
* leaderboard and nobody online, and each of those is a perfectly good answer to
|
||
* the question it was asked. Only this route can tell the page that the server
|
||
* itself is not there, which is what stops `/rust/servers/typo` rendering as a
|
||
* quiet server with nothing to say.
|
||
*/
|
||
async function getServer(req, res) {
|
||
try {
|
||
const server = await servers.getPublic(req.params.id)
|
||
if (!server) {
|
||
res.status(404).json({ message: 'No such server' })
|
||
return
|
||
}
|
||
res.json({ server })
|
||
} catch (err) {
|
||
log.error('failed to read a server', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the server' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The killfeed, and everything else public that happened on one server.
|
||
*
|
||
* **`admin` is not passed, and that is the whole security posture of this
|
||
* handler.** `events.recent` takes the viewer explicitly and defaults to the
|
||
* public allowlist, so the way to leak an IP address from here is to add an
|
||
* argument rather than to forget one.
|
||
*
|
||
* `presence` is resolved per request from the operator's setting. Below it, the
|
||
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
|
||
* so with `presenceHidden`, so a page can explain a quiet feed instead of
|
||
* implying a quiet server.
|
||
*
|
||
* `staff` is the same re-read rung, and reaches only what a world event carries
|
||
* (protocol 14, D353): a Steam id and a position. It never widens the kinds.
|
||
*
|
||
* The chat is read for THIS reader too (USER_SAFETY.md S11): lines whose speaker
|
||
* they block or that they hid are left out, a line staff hid for everyone is
|
||
* left out unless they are staff, and a signed-in reader gets `canBlock` on each
|
||
* line (S13). That is why this route was already `perViewer` and must stay so.
|
||
*/
|
||
async function listEvents(req, res) {
|
||
try {
|
||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||
const reader = { userId: presence.userId, staff: presence.level === 'staff' }
|
||
// Below the presence audience there is no chat to filter or mark.
|
||
const chat = presence.visible ? await safety.feedFilter(reader) : {}
|
||
const rows = await events.recent({
|
||
serverId: req.params.id,
|
||
presence: presence.visible,
|
||
staff: reader.staff,
|
||
kind: req.query.kind,
|
||
wipeId: req.query.wipe || null,
|
||
limit: req.query.limit,
|
||
chat,
|
||
})
|
||
perViewer(res)
|
||
res.json({
|
||
events: presence.visible ? await safety.annotate(rows, reader) : rows,
|
||
presenceHidden: !presence.visible,
|
||
presenceAudience: presence.required,
|
||
})
|
||
} catch (err) {
|
||
log.error('failed to read events', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read events' })
|
||
}
|
||
}
|
||
|
||
async function listLeaderboard(req, res) {
|
||
try {
|
||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||
const wipeId = req.query.wipe || null
|
||
|
||
// The richest column (§11.2.2): one wipe at a time, on a server that
|
||
// reports Economics. `economics` is null otherwise, and the rows carry no
|
||
// `balance`, so a page shows the column exactly when there is one.
|
||
const econ = await economics.forServer(req.params.id)
|
||
// Treasure Hunter's column (D375): on a server that reports Dangerous
|
||
// Treasures, and on any page whose rows already count a treasure won.
|
||
const dt = dangerousTreasures.reportOf(await serversDb.dtReportFor(req.params.id).catch(() => null))
|
||
// Warden's column (D375), likewise for Guarded Crate.
|
||
const gc = guardedCrate.reportOf(await serversDb.gcReportFor(req.params.id).catch(() => null))
|
||
// Warlord's column (§11.4, D202), likewise for RaidableBases.
|
||
const rb = raidableBases.reportOf(await serversDb.rbReportFor(req.params.id).catch(() => null))
|
||
const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null
|
||
|
||
const [rows, held] = await Promise.all([
|
||
events.leaderboard({
|
||
serverId: req.params.id,
|
||
wipeId,
|
||
sort: req.query.sort,
|
||
limit: req.query.limit,
|
||
presence: presence.visible,
|
||
economics: withBalance,
|
||
}),
|
||
titles.currentFor(req.params.id),
|
||
])
|
||
perViewer(res)
|
||
res.json({
|
||
// D137: the titles each player holds NOW — the same ones, after the same
|
||
// mode, that the game shows. They rank the current wipe whichever wipe
|
||
// this page is showing, because a title is what a player is, not what a
|
||
// past wipe was.
|
||
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
|
||
economics: withBalance,
|
||
dangerousTreasures: Boolean((dt.dangerousTreasures && dt.dangerousTreasures.loaded) || rows.some((r) => r.treasuresWon > 0)),
|
||
guardedCrate: Boolean((gc.guardedCrate && gc.guardedCrate.loaded) || rows.some((r) => r.guardedWon > 0)),
|
||
raidableBases: Boolean((rb.raidableBases && rb.raidableBases.loaded) || rows.some((r) => r.raidsCompleted > 0)),
|
||
})
|
||
} catch (err) {
|
||
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the leaderboard' })
|
||
}
|
||
}
|
||
|
||
/** GET /servers/:id/npc-profiles — what the leaderboard's profile picker offers (D250). */
|
||
async function listNpcProfiles(req, res) {
|
||
try {
|
||
res.json({ profiles: await npcs.boardProfiles(req.params.id) })
|
||
} catch (err) {
|
||
log.error('failed to read the NPC profiles', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the NPC profiles' })
|
||
}
|
||
}
|
||
|
||
/** GET /servers/:id/npc-leaderboard?profile= — one profile's kills, counted as it says (D247, D250). */
|
||
async function npcLeaderboard(req, res) {
|
||
const profileId = Number(req.query.profile)
|
||
if (!Number.isInteger(profileId) || profileId < 1) return res.status(400).json({ message: 'profile is a profile id' })
|
||
|
||
try {
|
||
const state = await serversDb.getState(req.params.id)
|
||
const wipeId = req.query.wipe ? String(req.query.wipe) : null
|
||
const currentWipe = Boolean(wipeId && state && state.wipeId === wipeId)
|
||
const [ranked, held] = await Promise.all([
|
||
npcs.ranking({ serverId: req.params.id, profileId, wipeId, currentWipe, limit: req.query.limit }),
|
||
titles.currentFor(req.params.id),
|
||
])
|
||
res.json({ profile: ranked.profile, leaderboard: ranked.rows.map((row) => ({ ...row, kills: row.value, titles: held.get(row.steamId) || [] })) })
|
||
} catch (err) {
|
||
if (err instanceof npcs.NpcError) return res.status(err.status).json({ message: err.message })
|
||
log.error('failed to read an NPC leaderboard', { server: req.params.id, error: err.message })
|
||
return res.status(500).json({ message: 'Failed to read the leaderboard' })
|
||
}
|
||
}
|
||
|
||
/** GET /servers/:id/players/:steamId/npc-kills — one leaderboard row, opened (D252). */
|
||
async function playerNpcKills(req, res) {
|
||
const steamId = String(req.params.steamId || '')
|
||
if (!/^\d{1,20}$/.test(steamId)) return res.status(400).json({ message: 'steamId is a Steam id' })
|
||
|
||
try {
|
||
const wipeId = req.query.wipe ? String(req.query.wipe) : null
|
||
res.json({ steamId, kills: await npcs.playerKills({ serverId: req.params.id, steamId, wipeId }) })
|
||
} catch (err) {
|
||
log.error('failed to read a player’s NPC kills', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the kills' })
|
||
}
|
||
}
|
||
|
||
async function listWipes(req, res) {
|
||
try {
|
||
res.json({ wipes: await events.wipes(req.params.id) })
|
||
} catch (err) {
|
||
log.error('failed to read wipes', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read wipes' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Who is on the server right now — or, below the operator's audience, how many.
|
||
*
|
||
* The count stays public: it is already on the server list and in the footer,
|
||
* and a number names nobody. The names do not, by default (the org lead's rule,
|
||
* `model/visibility`). A hidden answer is still a 200 with the same shape — an
|
||
* empty `players` array — plus `hidden` and `count`, so a client that predates
|
||
* the flag renders an empty list rather than breaking, and a current one can say
|
||
* "12 online" instead of "nobody".
|
||
*/
|
||
async function listOnline(req, res) {
|
||
try {
|
||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||
perViewer(res)
|
||
|
||
if (!presence.visible) {
|
||
const server = await servers.getPublic(req.params.id)
|
||
res.json({
|
||
players: [],
|
||
hidden: true,
|
||
count: server ? server.players : 0,
|
||
audience: presence.required,
|
||
})
|
||
return
|
||
}
|
||
|
||
const players = await events.online(req.params.id)
|
||
res.json({ players, hidden: false, count: players.length, audience: presence.required })
|
||
} catch (err) {
|
||
log.error('failed to read presence', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read who is online' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Who is asking, as core describes a viewer to `projectRoster`: `{ userId, role }`
|
||
* or null. Only the id is trusted — `model/clans` re-reads the row — so a token
|
||
* that cannot be decoded is simply nobody.
|
||
*/
|
||
function viewerOf(req) {
|
||
try {
|
||
const claimed = req.user || core.auth.getUserFromRequest(req)
|
||
if (!claimed || claimed.id == null) return null
|
||
return { userId: claimed.id, role: claimed.role || null }
|
||
} catch (err) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One server's clans (D58): name, colour, score and member count, best first.
|
||
*
|
||
* Public at every setting, because none of it names a player. `board` says
|
||
* whether the list can be trusted — a server whose plugin predates protocol 6,
|
||
* or whose clans the bridge cannot read, answers an empty list AND the reason,
|
||
* so the tab can say "unavailable" rather than "no clans".
|
||
*/
|
||
async function listClans(req, res) {
|
||
try {
|
||
res.json(await clans.listForServer(req.params.id))
|
||
} catch (err) {
|
||
log.error('failed to read clans', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read clans' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One clan, and its roster when the viewer is inside the roster audience (D48).
|
||
*
|
||
* The same decision core's `projectRoster` makes, from the same function, so
|
||
* this page and core's roster cannot disagree about who may look. Below the
|
||
* audience the clan is still described — its name and its count are public —
|
||
* and `roster.visible` is false with no names at all.
|
||
*/
|
||
async function getClan(req, res) {
|
||
try {
|
||
const answer = await clans.getForViewer(req.params.externalId, viewerOf(req))
|
||
perViewer(res)
|
||
if (!answer) {
|
||
res.status(404).json({ message: 'No such clan' })
|
||
return
|
||
}
|
||
res.json(answer)
|
||
} catch (err) {
|
||
log.error('failed to read a clan', { clan: req.params.externalId, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the clan' })
|
||
}
|
||
}
|
||
|
||
// ── The map (phase 14) ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* One server's map: the picture's address, the geometry to draw it with, the
|
||
* monuments when the viewer may see the world layer, and **which layers this
|
||
* viewer gets and who gets the others** — the §23.3 shape, where a hidden layer
|
||
* says who can see it and never what it holds.
|
||
*
|
||
* A server that has never described its map answers `picture: null` and
|
||
* `geometry: null`, and the page says the map is not available yet. A server
|
||
* whose game has no picture answers geometry and no picture, and the page draws
|
||
* the layers on a plain background (D109).
|
||
*/
|
||
async function getMap(req, res) {
|
||
try {
|
||
const server = await servers.getPublic(req.params.id)
|
||
if (!server) {
|
||
res.status(404).json({ message: 'No such server' })
|
||
return
|
||
}
|
||
|
||
const [row, acc] = await Promise.all([mapDb.getMeta(req.params.id), map.access(req, req.params.id)])
|
||
perViewer(res)
|
||
|
||
const hasPicture = Boolean(row && row.sha256 && Number(row.byteCount) > 0)
|
||
res.json({
|
||
serverId: req.params.id,
|
||
mapKey: row ? row.mapKey : null,
|
||
picture: hasPicture
|
||
? {
|
||
path: `/public/rust/servers/${encodeURIComponent(req.params.id)}/map/image?v=${row.sha256}`,
|
||
source: row.source,
|
||
fetchedAt: row.fetchedAt ? new Date(row.fetchedAt).toISOString() : null,
|
||
}
|
||
: null,
|
||
geometry: map.geometryOf(row),
|
||
// D196: only the labels drawn on this server; a hidden label is not sent at all.
|
||
...(acc.layers.world.visible ? { monuments: map.filterMonuments(map.monumentsOf(row), acc.markers) } : {}),
|
||
layers: acc.layers,
|
||
// `signedIn` is what lets the page offer "link your Steam account" to the
|
||
// person who can act on it, and not to a visitor who has no account at all.
|
||
mates: { visible: acc.mates.visible, on: acc.mates.on, linked: acc.mates.linked, signedIn: acc.level !== 'public' },
|
||
pollMs: map.POLL_MS,
|
||
})
|
||
} catch (err) {
|
||
log.error('failed to read a map', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the map' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The picture itself. **Public and immutable**: the hash is in the URL, so a
|
||
* browser and any cache in front of the site may keep it for a year — and a
|
||
* hash that is no longer the stored picture's is a 404, never the new bytes
|
||
* under the old address. The picture is public at every setting (§30.5): it is
|
||
* rendered from a seed anybody can render, and it says nothing about who plays.
|
||
*/
|
||
async function getMapImage(req, res) {
|
||
try {
|
||
const sha = String(req.query.v || '').toLowerCase()
|
||
const server = /^[0-9a-f]{64}$/.test(sha) ? await servers.getPublic(req.params.id) : null
|
||
const bytes = server ? await mapDb.getBytes(req.params.id, sha) : null
|
||
|
||
if (!bytes) {
|
||
res.set('Cache-Control', 'no-store')
|
||
res.status(404).json({ message: 'No such picture' })
|
||
return
|
||
}
|
||
|
||
res.set('Cache-Control', 'public, max-age=31536000, immutable')
|
||
res.set('Content-Type', 'image/jpeg')
|
||
res.set('X-Content-Type-Options', 'nosniff')
|
||
res.send(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes))
|
||
} catch (err) {
|
||
log.error('failed to read a map picture', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the map picture' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* What moves, **projected for this viewer on the server** (§30.2). A layer the
|
||
* viewer may not see is absent from the answer — not empty, absent — and
|
||
* `mates` carries their own position and their online clan mates when they are
|
||
* entitled to it (D115, D117). Positions come from `mapLive`'s five-second
|
||
* cache, so any number of viewers cost one ask of the game (D111).
|
||
*
|
||
* A game that does not answer is `live: false` with a reason, a 200: the page
|
||
* keeps the picture and says positions are unavailable.
|
||
*/
|
||
async function getMapLive(req, res) {
|
||
try {
|
||
const server = await servers.getForCalling(req.params.id)
|
||
if (!server) {
|
||
res.status(404).json({ message: 'No such server' })
|
||
return
|
||
}
|
||
|
||
const acc = await map.access(req, req.params.id)
|
||
perViewer(res)
|
||
|
||
const anyLayer = map.LAYERS.some((l) => acc.layers[l].visible) || acc.mates.visible
|
||
if (!anyLayer) {
|
||
res.json({ live: true, layers: acc.layers })
|
||
return
|
||
}
|
||
|
||
const answer = await mapLive.live(server)
|
||
if (!answer.ok) {
|
||
res.json({ live: false, reason: answer.status, layers: acc.layers })
|
||
return
|
||
}
|
||
|
||
const ids = await map.mateIdsFor(req.params.id, acc)
|
||
res.json({ live: true, layers: acc.layers, ...map.project(answer.data, acc, ids) })
|
||
} catch (err) {
|
||
log.error('failed to read live map positions', { server: req.params.id, error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the map' })
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
listServers,
|
||
getServer,
|
||
listEvents,
|
||
listLeaderboard,
|
||
listNpcProfiles,
|
||
npcLeaderboard,
|
||
playerNpcKills,
|
||
listWipes,
|
||
listOnline,
|
||
listClans,
|
||
getClan,
|
||
getMap,
|
||
getMapImage,
|
||
getMapLive,
|
||
}
|