// ── The `admin.users.detail` slot's handlers ────────────────────────────── // // What an operator can see and do about one website user's Rust identity. The // user id is the PARENT's — `req.params.id` off core's `/admin/users/:id` — and // every statement here is scoped by it, so a panel opened on one user cannot // read or write another's rows by editing a path segment. const crypto = require('node:crypto') const core = require('../../core') const economics = require('../../model/economics/economics.model') const links = require('../../model/links/links.model') const permissionsDb = require('../../model/permissions/permissions.db') const permissions = require('../../model/permissions/permissions.model') const servers = require('../../model/servers/servers.model') const log = core.logger('admin') /** * GET /admin/users/:id/rust/links * * The linked Steam accounts and, per server, what this module knows about the * player behind them — all-time rather than this wipe's, because an operator * looking at a user wants their history and the public leaderboard already * answers the other question. * * **An empty array is an answer.** Most users have no Rust link at all, and the * panel renders nothing rather than an error for them. */ async function listLinks(req, res) { try { res.json({ links: await links.forAdmin(req.params.id) }) } catch (err) { log.error('failed to read a user’s Rust links', { error: err.message }) res.status(500).json({ message: 'Failed to read this user’s Rust accounts' }) } } /** * DELETE /admin/users/:id/rust/links/:steamId — staff sever a link (D25). * * **This is the counterweight to D23.** The site refuses to move a Steam id that * another website account already holds, and the player's own way out is * `/unlink` in game — which is no way out at all for somebody who has lost access * to that Steam account, or to the site account holding it. Staff are that route. * * Scoped by the parent user id in the statement rather than checked first: the * ownership test and the deletion are one operation, and a link that belongs to a * different user answers 404 from the page it was not on. */ async function removeLink(req, res) { const { steamId } = req.params const userId = req.params.id try { const removed = await links.unlinkOwned(steamId, userId) if (!removed) return res.status(404).json({ message: 'That account is not linked to this user' }) // The one write this panel has, so it is the one thing here worth an audit // row: after phase 7 a link is what permissions are granted against, and // "who severed it" stops being a curiosity. await core.activity.log({ req, action: 'rust.account.unlink.staff', detail: { steamId, userId: Number(userId) }, }) return res.json({ unlinked: true }) } catch (err) { log.error('failed to unlink a Steam account', { error: err.message }) return res.status(500).json({ message: 'Failed to unlink that account' }) } } /** * GET /admin/users/:id/rust/permissions * * What this person may do in game, and — the part that is easy to leave out — * whether any of it reaches anybody. A grant against an account with no linked * Steam id is authored, stored, pushed nowhere and looks identical to a working * one on every screen that does not say so. */ async function listPermissions(req, res) { const userId = Number(req.params.id) try { const [groups, groupServers, groupPermissions, members, grants, allLinks] = await Promise.all([ permissionsDb.listGroups(), permissionsDb.listGroupServers(), permissionsDb.listGroupPermissions(), permissionsDb.listGroupMembers(), permissionsDb.listGrants({ userId }), permissionsDb.listLinks(), ]) const theirs = new Set(members.filter((row) => row.userId === userId).map((row) => row.groupId)) const carried = new Map() for (const row of groupPermissions) { if (!carried.has(row.groupId)) carried.set(row.groupId, []) carried.get(row.groupId).push(row.permission) } // A group is on one server unless it is shared (D189): `scope` says `*` // for every server, or lists the servers it is on. const on = new Map() for (const row of groupServers) { if (!row.included) continue if (!on.has(row.groupId)) on.set(row.groupId, []) on.get(row.groupId).push(row.serverId) } res.json({ groups: groups .filter((group) => theirs.has(group.id)) .map((group) => ({ id: group.id, name: group.name, title: group.title, scope: group.allServers ? permissions.FLEET : (on.get(group.id) || []).join(','), permissions: carried.get(group.id) || [], })), grants: permissions.collapseGrants(grants).map((grant) => ({ id: grant.id, permission: grant.permission, scope: grant.scope, source: grant.source, note: grant.note, grantedAt: grant.grantedAt, })), reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId), }) } catch (err) { log.error('failed to read a user’s Rust permissions', { error: err.message }) res.status(500).json({ message: 'Failed to read this user’s Rust permissions' }) } } /** POST /admin/users/:id/rust/permissions/grants */ async function addGrant(req, res) { const userId = Number(req.params.id) const permission = permissions.normaliseName(req.body.permission) const scope = String(req.body.scope || permissions.FLEET) try { if (scope !== permissions.FLEET) { const known = await servers.listForAdmin() if (!known.some((row) => row.id === scope)) { return res.status(400).json({ message: 'That scope names no configured server' }) } } const { inserted } = await permissionsDb.insertGrant({ userId, permission, scope, source: 'admin', note: null, grantedBy: req.user ? req.user.id : null, }) if (inserted) { await permissionsDb.markDirty(scope) await core.activity.log({ req, action: 'rust.perm.grant', detail: { userId, permission, scope }, }) } return res.status(inserted ? 201 : 200).json({ granted: inserted }) } catch (err) { log.error('failed to grant a permission', { userId, permission, error: err.message }) return res.status(400).json({ message: 'That permission could not be granted' }) } } /** * DELETE /admin/users/:id/rust/permissions/grants/:grantId * * **Scoped by the user as well as by the grant**, like every other write in this * panel: a grant id belonging to somebody else answers `404` rather than * removing a privilege from a person whose page nobody was looking at. */ async function removeGrant(req, res) { const userId = Number(req.params.id) const grantId = Number(req.params.grantId) try { const grant = await permissionsDb.getGrant(grantId) if (!grant || grant.userId !== userId) { return res.status(404).json({ message: 'That grant does not belong to this user' }) } await permissionsDb.deleteGrant(grantId) await permissionsDb.markDirty(grant.scope) await core.activity.log({ req, action: 'rust.perm.revoke', detail: { userId, permission: grant.permission, scope: grant.scope }, }) return res.status(204).end() } catch (err) { log.error('failed to remove a grant', { userId, grant: grantId, error: err.message }) return res.status(500).json({ message: 'Failed to remove that permission' }) } } /** GET /admin/users/:id/rust/economics — this user's balances (§11.2.2), as the player sees them. */ async function listEconomics(req, res) { try { const held = await links.listForUser(Number(req.params.id)) res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) }) } catch (err) { log.error('failed to read a user’s balances', { error: err.message }) res.status(500).json({ message: 'Failed to read this user’s balances' }) } } /** * POST /admin/users/:id/rust/economics — staff deposit, withdraw or set coins. * * **Scoped by the user**, like every write in this panel: the Steam id must be * one this user has linked, so a wrong id on the form moves nobody's coins * rather than a stranger's. The key is the form's, one per submission; a form * that sent none gets one, and is then only as safe as a single click. */ async function adjustEconomics(req, res) { const userId = Number(req.params.id) const { serverId, steamId, op, amount } = req.body const key = req.body.key ? `adj-${req.body.key}` : `adj-${crypto.randomUUID()}` try { const held = await links.listForUser(userId) const link = held.find((l) => String(l.steamId) === String(steamId)) if (!link) return res.status(404).json({ message: 'That Steam account is not linked to this user' }) const result = await economics.adjust({ serverId, steamId: String(steamId), op, amount, key }) if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) }) // A repeat moved nothing, so it is not a second row in the log. if (!result.repeat) { await core.activity.log({ req, action: 'rust.economics.adjust', detail: { userId, serverId, steamId: String(steamId), op, amount: result.amount, before: result.before, after: result.after, summary: economics.adjustmentSummary({ op, amount: result.amount, name: link.name || String(steamId), server: result.server, before: result.before, after: result.after, }), }, }) } return res.json({ before: result.before, after: result.after, repeat: result.repeat }) } catch (err) { log.error('failed to adjust a balance', { userId, serverId, op, error: err.message }) return res.status(500).json({ message: 'Failed to adjust that balance' }) } } module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics }