Files
Module-Rust/server/router/admin/usersRust.controller.js
wtclaude e4591af906
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / frozen-manifest (pull_request) Successful in 1m0s
PR Checks / client-build (pull_request) Successful in 7m23s
feat(rust): Backpacks — staff and player views, the Bigger backpack step (PLAN_REDESIGNS §11.3)
The site half of §11.3.2, on protocol 14:

- `rust.backpack.size` ("Bigger backpack"): a kit reward with a different
  permission (D337) — one rust_perm_run_grants row per website user with
  backpacks.size.N, no kit, no credit; the perm mirror pushes it and the run's
  revert deletes it. risk change, reversible ledger, a new `rust.backpacks`
  budget, rust.kit.entitle's recipient modes including `linked`. Refused with
  "Backpacks is not installed on this server" in the dry run too (D199), and a
  size the server does not offer is refused by name. Its message is never
  silent: empty is the take-back warning (D338).
- `rust.options.backpacksizes`: each server's sizes above its smallest, from
  the last hello, grouped by server.
- GET /player/rust/backpacks (own links only, 20 reads a minute) and
  GET /admin/users/:id/rust/backpacks (that user's links only). Live, never
  stored; a stranger's id is a 404 and never reaches the game.
- Client: a shared Backpack panel (pick server and account, open, read once)
  on the account page and the admin user page; the grid draws pages of
  pageSize, each slot named by the game (D340, no icons), blueprints by what
  they make (D341), overflow marked. Backpacks joins the server's mod line.

Server 548/548 (13 new in test/backpacks.test.js), client 79/79 (5 new),
routes.manifest regenerated against the pinned core (89 routes), swagger
fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 14:35:55 -05:00

302 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 backpacks = require('../../model/backpacks/backpacks.model')
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' })
}
}
/**
* GET /admin/users/:id/rust/backpacks — one of this user's backpacks
* (§11.3.2), read live. Scoped by the user like every route in this panel: a
* Steam id they have not linked is a 404, never a stranger's backpack.
*/
async function readBackpack(req, res) {
const userId = Number(req.params.id)
const { serverId, steamId } = req.query
try {
const held = await links.listForUser(userId)
if (!held.some((l) => String(l.steamId) === String(steamId))) {
return res.status(404).json({ message: 'That Steam account is not linked to this user' })
}
const result = await backpacks.read({ serverId, steamId })
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
return res.json(result.backpack)
} catch (err) {
log.error('failed to read a backpack', { userId, serverId, error: err.message })
return res.status(500).json({ message: 'Failed to read that backpack' })
}
}
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics, readBackpack }