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
307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
// ── 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 }
|