Files
Module-Rust/server/router/public/rust.controller.js
wtclaude 0cb9bdd1f0
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s
feat(rust): the live map (phase 14, protocol 11)
PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

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

354 lines
13 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 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,
}