// ── 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 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 servers = require('../../model/servers/servers.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. */ async function listEvents(req, res) { try { const presence = await visibility.canSeePresence(req, req.params.id) perViewer(res) res.json({ events: await events.recent({ serverId: req.params.id, presence: presence.visible, kind: req.query.kind, wipeId: req.query.wipe || null, limit: req.query.limit, }), 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) perViewer(res) res.json({ leaderboard: await events.leaderboard({ serverId: req.params.id, wipeId: req.query.wipe || null, sort: req.query.sort, limit: req.query.limit, presence: presence.visible, }), }) } 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' }) } } 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), ...(acc.layers.world.visible ? { monuments: map.monumentsOf(row) } : {}), 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, listWipes, listOnline, listClans, getClan, getMap, getMapImage, getMapLive, }