Files
Module-Rust/server/router/admin/usersRust.controller.js
wtclaude fb7511d700
All checks were successful
PR Checks / server-tests (pull_request) Successful in 54s
PR Checks / client-build (pull_request) Successful in 1m5s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
fix(demo): lock removing a Steam link on the demo (DM27)
The step-5 walk found demo-player could remove its stand-in Steam link from
its Rust page, and staff could remove it from Admin -> Users. That link is
what the player tour's Rust pages, rewards and grants hang on.

- module.json demoLocks gains DELETE /player/rust/links/:steamId.
- The staff route, DELETE /admin/users/:id/rust/links/:steamId, serves under
  core's users mount, which a module's demoLocks may not name, so its handler
  refuses with DEMO_LOCKED behind ctx.demoMode before the link is touched.

Off the demo both behave as before. Checked on a real core with the demo
seed: both answer 403 DEMO_LOCKED and the link stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:28 -05:00

307 lines
12 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 demo = require('../../demo')
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
// On the demo the player's own unlink is locked by demoLocks; this route serves
// under core's users mount, which a module may not lock by route, so it asks here.
if (demo.isDemo()) return demo.refuse(res)
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 }