PLAN_REDESIGNS section 1. - Every sync reads the store (perm.inventory), reconciles it against the site's record and its ledger, and pushes. A change made in the game is settled by the server's policy (D161): auto-adopt (default), adopt, or revoke. The first read of a server imports everything (D198). - Groups belong to one server unless an admin shares them (D189), in new id-keyed tables; the old ones are copied once at boot and left unread. Holders may be a Steam account nobody linked (D188). - An in-game change affects that server only (D190): a grant that reaches further gains an exception, a shared group is split. - Never judged: a permission the server does not register right now (an unloaded plugin is not a revocation), and a pair an event lease holds. - A new admin API (server view, grant/revoke with everywhere-or-here, groups by id, share/split, members, drift answers) and a screen on PermissionsManager's flow with a state on every toggle (D162, D163, U-1). - The announcement voice names a group by id; old name settings still read. Walked on both rigs against the walk core: import on an existing install, auto-adopt of a grant and a revoke, a fleet grant's exception, Kits unloaded without loss, a shared group split, adopt and revoke policies. Server 420/420, client 58/58, swagger, imports and route manifest current. Refs #21 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
784 lines
33 KiB
JavaScript
784 lines
33 KiB
JavaScript
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||
//
|
||
// The permission manager (PLAN_REDESIGNS §1). The site owns every permission and
|
||
// group on every server (D160), and this is where a person changes them. Every
|
||
// write here goes to the site's own tables and marks the affected servers
|
||
// dirty; nothing here talks to a game. The push is `permSync.js`'s loop, which
|
||
// is deliberate — a form that wrote to six game hosts inside the request would
|
||
// fail differently for each and have no honest status code to answer with.
|
||
//
|
||
// **The one exception is "sync now"**, which runs the loop's pass for one server
|
||
// and waits for it, so an operator who just changed something can see it land.
|
||
//
|
||
// The screen works one server at a time, as uMod PermissionsManager does (D162).
|
||
// A write that could reach further says so and asks: a toggle for a fleet-wide
|
||
// grant changes it everywhere or on this server alone (an exception), and a
|
||
// change to a shared group changes it everywhere or splits this server's copy
|
||
// off (D190's own two answers, offered to a person).
|
||
//
|
||
// Every write logs an activity row. These rows decide who may do what inside
|
||
// somebody's game server.
|
||
|
||
const core = require('../../core')
|
||
|
||
const apply = require('../../model/permissions/permissions.apply')
|
||
const chatStyle = require('../../model/permissions/chatStyle')
|
||
const db = require('../../model/permissions/permissions.db')
|
||
const model = require('../../model/permissions/permissions.model')
|
||
const reconcile = require('../../model/permissions/reconcile')
|
||
const view = require('../../model/permissions/permissions.view')
|
||
const permSync = require('../../permSync')
|
||
const servers = require('../../model/servers/servers.model')
|
||
|
||
const log = core.logger('admin:permissions')
|
||
|
||
const by = (req) => (req.user ? req.user.id : null)
|
||
|
||
/** Whether a server id names a server row. A disabled server still counts — it exists. */
|
||
async function knownServer(id) {
|
||
const rows = await servers.listForAdmin()
|
||
return rows.some((row) => row.id === id)
|
||
}
|
||
|
||
/** The website account that holds a Steam id, or null. */
|
||
async function holderOf(steamId) {
|
||
const links = await db.listLinks()
|
||
return links.find((link) => link.steamId === steamId) || null
|
||
}
|
||
|
||
/** The user id a write names, from an id or a username. */
|
||
async function resolveUser(body) {
|
||
if (body.userId) return Number(body.userId)
|
||
if (!body.username) return null
|
||
|
||
const user = await db.findUserByUsername(String(body.username).trim())
|
||
return user ? user.id : null
|
||
}
|
||
|
||
/** The servers a group is on, for marking them dirty. */
|
||
async function serversOfGroup(group) {
|
||
const [serverRows, groupServers] = await Promise.all([servers.listForAdmin(), db.listGroupServers()])
|
||
return model.groupReach(group, model.serversByGroup(groupServers), serverRows.map((row) => row.id))
|
||
}
|
||
|
||
function fail(res, err, what) {
|
||
log.error(`failed to ${what}`, { error: err.message })
|
||
return res.status(500).json({ message: `Failed to ${what}` })
|
||
}
|
||
|
||
// ── Reads ────────────────────────────────────────────────────────────────
|
||
|
||
/** The servers, each one's policy and sync state, and everything waiting for a person. */
|
||
async function overview(req, res) {
|
||
try {
|
||
res.json({ ...(await view.overview()), chatFields: chatStyle.FIELDS, policies: permSync.POLICIES })
|
||
} catch (err) {
|
||
return fail(res, err, 'read the permission overview')
|
||
}
|
||
}
|
||
|
||
/** One server: plugins by owner, groups, players, and the facts behind every toggle's state. */
|
||
async function server(req, res) {
|
||
try {
|
||
const found = await view.serverView(String(req.params.serverId))
|
||
if (!found) return res.status(404).json({ message: 'No such server' })
|
||
res.json({ ...found, chatFields: chatStyle.FIELDS })
|
||
} catch (err) {
|
||
return fail(res, err, 'read that server’s permissions')
|
||
}
|
||
}
|
||
|
||
/** Players seen on a server, by name, Steam id or linked account — to grant to somebody who holds nothing yet. */
|
||
async function players(req, res) {
|
||
try {
|
||
const serverId = String(req.params.serverId)
|
||
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
|
||
|
||
const rows = await db.searchPlayers(serverId, String(req.query.q || ''), 25)
|
||
res.json({
|
||
players: rows.map((row) => ({
|
||
steamId: row.steamId,
|
||
name: row.playerName || null,
|
||
account: row.userId ? { userId: row.userId, username: row.username } : null,
|
||
})),
|
||
})
|
||
} catch (err) {
|
||
return fail(res, err, 'search the players')
|
||
}
|
||
}
|
||
|
||
/** Every permission name any server has registered, with which servers know it and who registered it. */
|
||
async function catalogue(req, res) {
|
||
try {
|
||
const rows = await db.listCatalogue()
|
||
const byPermission = new Map()
|
||
|
||
for (const row of rows) {
|
||
if (!byPermission.has(row.permission)) byPermission.set(row.permission, { permission: row.permission, servers: [], owner: null })
|
||
const entry = byPermission.get(row.permission)
|
||
entry.servers.push(row.serverId)
|
||
if (row.owner && !entry.owner) entry.owner = row.owner
|
||
}
|
||
|
||
res.json({ permissions: [...byPermission.values()].sort((a, b) => a.permission.localeCompare(b.permission)) })
|
||
} catch (err) {
|
||
return fail(res, err, 'read the permission catalogue')
|
||
}
|
||
}
|
||
|
||
// ── The server's policy (D161) ─────────────────────────────────────────────
|
||
|
||
async function setPolicy(req, res) {
|
||
const serverId = String(req.params.serverId)
|
||
const policy = String(req.body.policy || '')
|
||
|
||
try {
|
||
if (!permSync.POLICIES.includes(policy)) {
|
||
return res.status(400).json({ message: `The policy is one of ${permSync.POLICIES.join(', ')}` })
|
||
}
|
||
if (!(await db.setPolicy(serverId, policy))) return res.status(404).json({ message: 'No such server' })
|
||
|
||
await db.markDirty(serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.policy', detail: { server: serverId, policy } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'set the policy')
|
||
}
|
||
}
|
||
|
||
// ── Grants: the toggles, and Grant all / Revoke all ───────────────────────
|
||
|
||
/**
|
||
* Grant permissions to one subject on one server, or everywhere.
|
||
*
|
||
* The subject is a Steam id or a website account. A Steam id that is linked is
|
||
* granted as its ACCOUNT, reaching every Steam id that person links (D28, D188);
|
||
* an unlinked one is granted as itself. A grant kept off this server by an
|
||
* exception has its exception removed rather than a second grant written.
|
||
*/
|
||
async function grant(req, res) {
|
||
const serverId = String(req.params.serverId)
|
||
const everywhere = req.body.everywhere === true
|
||
const scope = everywhere ? model.FLEET : serverId
|
||
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
|
||
|
||
try {
|
||
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
|
||
|
||
let userId = await resolveUser(req.body)
|
||
const steamId = req.body.steamId ? String(req.body.steamId) : null
|
||
if (!userId && steamId) {
|
||
const link = await holderOf(steamId)
|
||
if (link) userId = link.userId
|
||
}
|
||
if (!userId && !steamId) return res.status(400).json({ message: 'Name a Steam id or a website account' })
|
||
|
||
const holder = userId ? 'user' : 'steam'
|
||
const exceptions = (await db.listExceptions()).filter((e) => e.serverId === serverId && e.holder === holder)
|
||
// One entry per grant: the user list repeats a grant once per linked account.
|
||
const existing = new Map(
|
||
(userId ? await db.listGrants({ userId }) : await db.listSteamGrants({ steamId })).map((g) => [g.id, g]),
|
||
)
|
||
let granted = 0
|
||
let restored = 0
|
||
|
||
for (const permission of permissions) {
|
||
// A grant of this permission that reaches this server but is kept off it
|
||
// by an exception: the exception is the thing to undo.
|
||
const exception = exceptions.find((e) => {
|
||
const g = existing.get(Number(e.grantId)) || existing.get(e.grantId)
|
||
return g && model.normaliseName(g.permission) === permission && model.inScope(g.scope, serverId)
|
||
})
|
||
|
||
if (exception) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await db.deleteException(exception.id)
|
||
restored++
|
||
continue
|
||
}
|
||
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const result = userId
|
||
? await db.insertGrant({ userId, permission, scope, source: 'admin', note: null, grantedBy: by(req) })
|
||
: await db.insertSteamGrant({ steamId, permission, scope, source: 'admin', grantedBy: by(req) })
|
||
if (result.inserted) granted++
|
||
}
|
||
|
||
await db.markDirty(scope)
|
||
await core.activity.log({
|
||
req,
|
||
action: 'rust.perm.grant',
|
||
detail: { server: serverId, scope, userId, steamId: userId ? null : steamId, permissions },
|
||
})
|
||
|
||
return res.json({ granted, restored })
|
||
} catch (err) {
|
||
return fail(res, err, 'grant those permissions')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Take permissions away from one subject on one server, or everywhere.
|
||
*
|
||
* Every direct grant that puts the permission on this server stops doing so: one
|
||
* scoped to this server alone is deleted; one that reaches further is deleted
|
||
* with `everywhere`, and otherwise gains an exception for this server (D190).
|
||
* What the subject holds THROUGH A GROUP, or from an event, is not a direct grant
|
||
* and is reported back rather than touched.
|
||
*/
|
||
async function revoke(req, res) {
|
||
const serverId = String(req.params.serverId)
|
||
const everywhere = req.body.everywhere === true
|
||
const permissions = new Set([...(req.body.permissions || [])].map(model.normaliseName).filter(Boolean))
|
||
|
||
try {
|
||
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
|
||
|
||
const userId = await resolveUser(req.body)
|
||
let steamIds = req.body.steamId ? [String(req.body.steamId)] : []
|
||
if (userId) steamIds = (await db.listLinks()).filter((l) => l.userId === userId).map((l) => l.steamId)
|
||
if (!steamIds.length && !userId) return res.status(400).json({ message: 'Name a Steam id or a website account' })
|
||
|
||
const desired = model.buildDesired(serverId, await model.readAuthored())
|
||
const done = new Set()
|
||
const untouched = []
|
||
let revoked = 0
|
||
|
||
for (const steamId of steamIds) {
|
||
for (const permission of permissions) {
|
||
const sources = desired.sources.get(model.rowKey({ kind: 'grant', subject: steamId, object: permission })) || []
|
||
|
||
for (const source of sources) {
|
||
if (source.type === 'runGrant') {
|
||
untouched.push({ permission, why: 'an event gave it; its revert takes it back' })
|
||
continue
|
||
}
|
||
|
||
const holder = source.type === 'userGrant' ? 'user' : 'steam'
|
||
const key = `${holder}:${source.id}`
|
||
if (done.has(key)) continue
|
||
done.add(key)
|
||
|
||
/* eslint-disable no-await-in-loop */
|
||
if (source.scope === serverId || everywhere) {
|
||
if (holder === 'user') await db.deleteGrant(source.id)
|
||
else await db.deleteSteamGrant(source.id)
|
||
} else {
|
||
await db.addException({ holder, grantId: source.id, serverId, createdBy: by(req) })
|
||
}
|
||
/* eslint-enable no-await-in-loop */
|
||
revoked++
|
||
}
|
||
}
|
||
}
|
||
|
||
await db.markDirty(everywhere ? model.FLEET : serverId)
|
||
await core.activity.log({
|
||
req,
|
||
action: 'rust.perm.revoke',
|
||
detail: { server: serverId, everywhere, userId, steamIds, permissions: [...permissions] },
|
||
})
|
||
|
||
return res.json({ revoked, untouched })
|
||
} catch (err) {
|
||
return fail(res, err, 'revoke those permissions')
|
||
}
|
||
}
|
||
|
||
/** Remove an exception: the grant reaches that server again. */
|
||
async function removeException(req, res) {
|
||
try {
|
||
const exceptions = await db.listExceptions()
|
||
const found = exceptions.find((e) => Number(e.id) === Number(req.params.id))
|
||
if (!found) return res.status(404).json({ message: 'No such exception' })
|
||
|
||
await db.deleteException(found.id)
|
||
await db.markDirty(found.serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.exception.remove', detail: { server: found.serverId, holder: found.holder, grantId: found.grantId } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'remove that exception')
|
||
}
|
||
}
|
||
|
||
// ── Groups (D189) ─────────────────────────────────────────────────────────
|
||
|
||
/** Title, rank, parent and style from a body, validated. `null` fields are left out. */
|
||
function groupFields(body) {
|
||
const out = {}
|
||
if (body.title !== undefined) out.title = String(body.title)
|
||
if (body.rank !== undefined) out.rank = Number(body.rank) || 0
|
||
if (body.parent !== undefined) out.parent = model.normaliseName(body.parent)
|
||
return out
|
||
}
|
||
|
||
/** A new group on one server. The model refuses a second group of a name on a server. */
|
||
async function createGroup(req, res) {
|
||
const serverId = String(req.params.serverId)
|
||
const name = model.normaliseName(req.body.name)
|
||
|
||
try {
|
||
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
|
||
if (await apply.groupOn(name, serverId)) {
|
||
return res.status(409).json({ message: `This server already has a group called "${name}"` })
|
||
}
|
||
|
||
const fields = groupFields(req.body)
|
||
const id = await db.insertGroup({ name, title: fields.title ?? name, rank: fields.rank ?? 0, parent: fields.parent ?? '' })
|
||
await db.setGroupServers(id, { allServers: false, servers: [serverId] })
|
||
|
||
await db.markDirty(serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.group.create', detail: { server: serverId, group: name, id } })
|
||
return res.status(201).json({ id })
|
||
} catch (err) {
|
||
return fail(res, err, 'create that group')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Change a group's title, rank, parent or style. With `serverId` and a shared
|
||
* group, `onlyHere` splits that server's copy off first (D190) and changes the
|
||
* copy; otherwise the change is the group's, on every server it is on.
|
||
*/
|
||
async function updateGroup(req, res) {
|
||
try {
|
||
let group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
let style
|
||
if (req.body.chat !== undefined && req.body.chat !== null) {
|
||
const checked = chatStyle.validateStyle(req.body.chat)
|
||
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
|
||
style = checked.fields
|
||
} else if (req.body.chat === null) {
|
||
style = null
|
||
}
|
||
|
||
const dirty = await serversOfGroup(group)
|
||
if (req.body.onlyHere && req.body.serverId) {
|
||
group = await apply.ownGroup(group.name, String(req.body.serverId))
|
||
if (!group) return res.status(409).json({ message: 'That group is not on that server' })
|
||
}
|
||
|
||
const fields = groupFields(req.body)
|
||
if (Object.keys(fields).length) {
|
||
await db.updateGroup(group.id, {
|
||
title: fields.title ?? group.title,
|
||
rank: fields.rank ?? group.rank,
|
||
parent: fields.parent ?? group.parent,
|
||
})
|
||
}
|
||
if (style !== undefined) await db.setGroupChat(group.id, style)
|
||
|
||
await db.markDirty(dirty)
|
||
await core.activity.log({ req, action: 'rust.perm.group.update', detail: { id: group.id, group: group.name, ...fields, chat: style === undefined ? undefined : Boolean(style) } })
|
||
return res.json({ id: group.id })
|
||
} catch (err) {
|
||
return fail(res, err, 'change that group')
|
||
}
|
||
}
|
||
|
||
/** Delete a group everywhere it is. A built-in group is never deleted from the game. */
|
||
async function deleteGroup(req, res) {
|
||
try {
|
||
const group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
const dirty = await serversOfGroup(group)
|
||
await db.deleteGroup(group.id)
|
||
await db.markDirty(dirty)
|
||
await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { id: group.id, group: group.name } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'delete that group')
|
||
}
|
||
}
|
||
|
||
/** Replace what a group carries (the toggles, Grant all, Revoke all) — here only, or everywhere it is. */
|
||
async function setGroupPermissions(req, res) {
|
||
try {
|
||
let group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
const dirty = await serversOfGroup(group)
|
||
if (req.body.onlyHere && req.body.serverId) {
|
||
group = await apply.ownGroup(group.name, String(req.body.serverId))
|
||
if (!group) return res.status(409).json({ message: 'That group is not on that server' })
|
||
}
|
||
|
||
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
|
||
await db.setGroupPermissions(group.id, permissions)
|
||
await db.markDirty(dirty)
|
||
await core.activity.log({ req, action: 'rust.perm.group.permissions', detail: { id: group.id, group: group.name, count: permissions.length } })
|
||
return res.json({ id: group.id })
|
||
} catch (err) {
|
||
return fail(res, err, 'change what that group carries')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Share a group, or stop sharing it (D189). `allServers`, or a list of servers.
|
||
*
|
||
* A server that already has its own group of this name is a conflict: the answer
|
||
* is 409 naming them, and the request is repeated with `replace` listing the ids
|
||
* the admin chose to replace.
|
||
*/
|
||
async function setGroupServers(req, res) {
|
||
try {
|
||
const group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
const serverRows = await servers.listForAdmin()
|
||
const known = new Set(serverRows.map((row) => row.id))
|
||
const allServers = req.body.allServers === true
|
||
const list = [...new Set((req.body.servers || []).map(String))].filter((id) => known.has(id))
|
||
const targets = allServers ? [...known] : list
|
||
|
||
const [groups, groupServers, groupPermissions] = await Promise.all([
|
||
db.listGroups(),
|
||
db.listGroupServers(),
|
||
db.listGroupPermissions(),
|
||
])
|
||
const byGroup = model.serversByGroup(groupServers)
|
||
const conflicts = groups.filter((other) =>
|
||
other.id !== group.id && other.name === group.name &&
|
||
targets.some((serverId) => model.groupCovers(other, byGroup, serverId)))
|
||
|
||
const replace = new Set((req.body.replace || []).map(Number))
|
||
const unanswered = conflicts.filter((other) => !replace.has(other.id))
|
||
if (unanswered.length) {
|
||
// What each conflicting group carries, so the screen can show the difference.
|
||
const carried = (id) => groupPermissions.filter((row) => row.groupId === id).map((row) => row.permission).sort()
|
||
return res.status(409).json({
|
||
message: 'Some of those servers already have their own group of this name. Choose which to replace.',
|
||
group: { id: group.id, permissions: carried(group.id) },
|
||
conflicts: unanswered.map((other) => ({
|
||
id: other.id,
|
||
title: other.title,
|
||
servers: model.groupReach(other, byGroup, [...known]),
|
||
permissions: carried(other.id),
|
||
})),
|
||
})
|
||
}
|
||
|
||
const before = model.groupReach(group, byGroup, [...known])
|
||
for (const other of conflicts) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await db.deleteGroup(other.id)
|
||
}
|
||
|
||
await db.setGroupServers(group.id, { allServers, servers: allServers ? [] : list })
|
||
await db.markDirty([...new Set([...before, ...targets])])
|
||
await core.activity.log({
|
||
req,
|
||
action: 'rust.perm.group.servers',
|
||
detail: { id: group.id, group: group.name, allServers, servers: list, replaced: conflicts.map((c) => c.id) },
|
||
})
|
||
return res.json({ id: group.id })
|
||
} catch (err) {
|
||
return fail(res, err, 'change where that group is')
|
||
}
|
||
}
|
||
|
||
/** Give one server its own copy of a shared group (D190, asked for by a person). */
|
||
async function splitGroup(req, res) {
|
||
try {
|
||
const group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
const serverId = String(req.body.serverId || '')
|
||
if (!(await knownServer(serverId))) return res.status(400).json({ message: 'Name the server to split off' })
|
||
|
||
const own = await apply.ownGroup(group.name, serverId)
|
||
if (!own) return res.status(409).json({ message: 'That group is not on that server' })
|
||
|
||
await db.markDirty(serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.group.split', detail: { id: group.id, group: group.name, server: serverId, copy: own.id } })
|
||
return res.json({ id: own.id })
|
||
} catch (err) {
|
||
return fail(res, err, 'split that group')
|
||
}
|
||
}
|
||
|
||
/** Put a subject in a group: a Steam id (as itself, D188) or a website account (D28). */
|
||
async function addMember(req, res) {
|
||
try {
|
||
let group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
const dirty = await serversOfGroup(group)
|
||
if (req.body.onlyHere && req.body.serverId) group = await apply.ownGroup(group.name, String(req.body.serverId))
|
||
|
||
const userId = await resolveUser(req.body)
|
||
const steamId = req.body.steamId ? String(req.body.steamId) : null
|
||
if (!userId && !steamId) return res.status(400).json({ message: 'Name a Steam id or a website account' })
|
||
|
||
if (userId) await db.addGroupMember(group.id, userId, by(req))
|
||
else await db.addGroupSteamMember(group.id, steamId, { addedBy: by(req) })
|
||
|
||
await db.markDirty(dirty)
|
||
await core.activity.log({ req, action: 'rust.perm.member.add', detail: { id: group.id, group: group.name, userId, steamId } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'add that member')
|
||
}
|
||
}
|
||
|
||
/** Take a subject out of a group — both ways it can be in it, as a Steam id and as that id's account. */
|
||
async function removeMember(req, res) {
|
||
try {
|
||
let group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
const dirty = await serversOfGroup(group)
|
||
if (req.body.onlyHere && req.body.serverId) group = await apply.ownGroup(group.name, String(req.body.serverId))
|
||
|
||
let userId = await resolveUser(req.body)
|
||
const steamId = req.body.steamId ? String(req.body.steamId) : null
|
||
if (steamId) {
|
||
await db.removeGroupSteamMember(group.id, steamId)
|
||
const link = await holderOf(steamId)
|
||
if (link && !userId) userId = link.userId
|
||
}
|
||
if (userId) await db.removeGroupMember(group.id, userId)
|
||
|
||
await db.markDirty(dirty)
|
||
await core.activity.log({ req, action: 'rust.perm.member.remove', detail: { id: group.id, group: group.name, userId, steamId } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'remove that member')
|
||
}
|
||
}
|
||
|
||
/** Remove all: every member of a group, both kinds. */
|
||
async function clearMembers(req, res) {
|
||
try {
|
||
let group = await db.getGroup(Number(req.params.id))
|
||
if (!group) return res.status(404).json({ message: 'No such group' })
|
||
|
||
const dirty = await serversOfGroup(group)
|
||
if (req.body.onlyHere && req.body.serverId) group = await apply.ownGroup(group.name, String(req.body.serverId))
|
||
|
||
const [members, steamMembers] = await Promise.all([db.listGroupMembers(), db.listGroupSteamMembers()])
|
||
for (const userId of new Set(members.filter((m) => m.groupId === group.id).map((m) => m.userId))) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await db.removeGroupMember(group.id, userId)
|
||
}
|
||
for (const m of steamMembers.filter((row) => row.groupId === group.id)) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await db.removeGroupSteamMember(group.id, m.steamId)
|
||
}
|
||
|
||
await db.markDirty(dirty)
|
||
await core.activity.log({ req, action: 'rust.perm.member.clear', detail: { id: group.id, group: group.name } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'empty that group')
|
||
}
|
||
}
|
||
|
||
// ── What waits for a person (D161's `adopt`, and what no policy settles) ──
|
||
|
||
/** The reconcile's op for one drift row, as auto-adopt would have run it. */
|
||
function opFor(row, desiredSources) {
|
||
if (row.kind === 'group') {
|
||
if (row.direction === 'removed') return { op: 'dropGroup', group: row.subject }
|
||
const attrs = reconcile.parseGroupValue(row.detail) || { title: row.subject, rank: 0, parent: '' }
|
||
return { op: 'adoptGroup', name: row.subject, ...attrs, source: 'adopted' }
|
||
}
|
||
if (row.kind === 'group-permission') return row.direction === 'removed'
|
||
? { op: 'dropGroupPermission', group: row.subject, permission: row.object }
|
||
: { op: 'adoptGroupPermission', group: row.subject, permission: row.object, source: 'adopted' }
|
||
if (row.kind === 'member') return row.direction === 'removed'
|
||
? { op: 'dropMember', group: row.object, steamId: row.subject }
|
||
: { op: 'adoptMember', group: row.object, steamId: row.subject, source: 'adopted' }
|
||
return row.direction === 'removed'
|
||
? { op: 'dropGrant', steamId: row.subject, permission: row.object, sources: desiredSources.get(model.rowKey(row)) || [] }
|
||
: { op: 'adoptGrant', steamId: row.subject, permission: row.object, source: 'adopted' }
|
||
}
|
||
|
||
/**
|
||
* Adopt: an addition made in the game becomes the site's, for that server (the
|
||
* same write auto-adopt makes). For a `chat-field` row, the game's value becomes
|
||
* the group's style.
|
||
*/
|
||
async function adoptDrift(req, res) {
|
||
try {
|
||
const row = await db.getDrift(Number(req.params.id))
|
||
if (!row) return res.status(404).json({ message: 'No such change' })
|
||
if (row.kind === 'chat-field') return adoptStyleField(req, res, row)
|
||
if (row.direction !== 'added' && row.direction !== 'changed') {
|
||
return res.status(400).json({ message: 'That change was a removal: accept it, or put it back' })
|
||
}
|
||
|
||
if (row.direction === 'changed') {
|
||
// The game's title, rank and parent, carried on the row, become the group's here.
|
||
const attrs = reconcile.parseGroupValue(row.detail)
|
||
if (!attrs) return res.status(409).json({ message: 'That change no longer says what the game holds' })
|
||
await apply.applyOp(row.serverId, { op: 'setGroupAttrs', group: row.subject, ...attrs })
|
||
} else {
|
||
await apply.applyOp(row.serverId, opFor(row, new Map()))
|
||
}
|
||
|
||
await db.deleteDrift(row.id)
|
||
await db.markDirty(row.serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.drift.adopt', detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'adopt that change')
|
||
}
|
||
}
|
||
|
||
/** Revoke: an addition made in the game is removed from it at the next sync. */
|
||
async function revokeDrift(req, res) {
|
||
try {
|
||
const row = await db.getDrift(Number(req.params.id))
|
||
if (!row) return res.status(404).json({ message: 'No such change' })
|
||
if (row.kind === 'chat-field') return revokeStyleField(req, res, row)
|
||
if (row.direction !== 'added') return res.status(400).json({ message: 'Only an addition can be revoked' })
|
||
|
||
await db.queueRevocation({ serverId: row.serverId, kind: row.kind, subject: row.subject, object: row.object, requestedBy: by(req) })
|
||
await db.deleteDrift(row.id)
|
||
await db.markDirty(row.serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.drift.revoke', detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object } })
|
||
return res.status(202).json({ queued: true })
|
||
} catch (err) {
|
||
return fail(res, err, 'revoke that change')
|
||
}
|
||
}
|
||
|
||
/** Accept a removal made in the game: the site stops giving it on that server (D190). */
|
||
async function acceptDrift(req, res) {
|
||
try {
|
||
const row = await db.getDrift(Number(req.params.id))
|
||
if (!row) return res.status(404).json({ message: 'No such change' })
|
||
if (row.direction !== 'removed') return res.status(400).json({ message: 'Only a removal can be accepted' })
|
||
|
||
const desired = model.buildDesired(row.serverId, await model.readAuthored())
|
||
await apply.applyOp(row.serverId, opFor(row, desired.sources))
|
||
|
||
await db.deleteDrift(row.id)
|
||
await db.markDirty(row.serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.drift.accept', detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'accept that removal')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Put it back: a removal (or a changed group) made in the game is undone at the
|
||
* next sync. Forgetting the ledger's row is what does it — a row the site wants
|
||
* and has no record of pushing is pushed.
|
||
*/
|
||
async function restoreDrift(req, res) {
|
||
try {
|
||
const row = await db.getDrift(Number(req.params.id))
|
||
if (!row) return res.status(404).json({ message: 'No such change' })
|
||
if (row.direction !== 'removed' && row.direction !== 'changed') {
|
||
return res.status(400).json({ message: 'Only a removal or a changed group can be put back' })
|
||
}
|
||
|
||
if (row.direction === 'changed') await db.setPushedValue(row.serverId, { kind: 'group', subject: row.subject, object: '', value: null })
|
||
else await db.removePushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }])
|
||
|
||
await db.deleteDrift(row.id)
|
||
await db.markDirty(row.serverId)
|
||
await core.activity.log({ req, action: 'rust.perm.drift.restore', detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object } })
|
||
return res.status(202).json({ queued: true })
|
||
} catch (err) {
|
||
return fail(res, err, 'put that back')
|
||
}
|
||
}
|
||
|
||
/** Dismiss a notice — a split (D190) or an event's grant pushed back. */
|
||
async function dismissDrift(req, res) {
|
||
try {
|
||
const row = await db.getDrift(Number(req.params.id))
|
||
if (!row) return res.status(404).json({ message: 'No such notice' })
|
||
|
||
await db.deleteDrift(row.id)
|
||
await core.activity.log({ req, action: 'rust.perm.drift.dismiss', detail: { server: row.serverId, kind: row.kind, subject: row.subject, direction: row.direction } })
|
||
return res.status(204).end()
|
||
} catch (err) {
|
||
return fail(res, err, 'dismiss that notice')
|
||
}
|
||
}
|
||
|
||
/** A `chat-field` row: the game's value becomes the style of that server's group. */
|
||
async function adoptStyleField(req, res, row) {
|
||
const group = await apply.groupOn(row.subject, row.serverId)
|
||
const style = group ? await db.getGroupChat(group.id) : null
|
||
if (!style || style[row.object] === undefined) {
|
||
return res.status(409).json({ message: 'That group has no chat style on this site to adopt the change into' })
|
||
}
|
||
|
||
const field = chatStyle.FIELDS.find((f) => f.name === row.object)
|
||
const checked = field ? chatStyle.checkField(field, row.detail === null ? '' : row.detail) : { error: 'unknown field' }
|
||
if (checked.error) {
|
||
return res.status(409).json({ message: `The game's value cannot be adopted: ${checked.error}. Revoke it instead, or edit the style.` })
|
||
}
|
||
|
||
await db.setGroupChatField(group.id, row.object, checked.value)
|
||
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
|
||
await db.deleteDrift(row.id)
|
||
await db.markDirty(await serversOfGroup(group))
|
||
|
||
await core.activity.log({ req, action: 'rust.perm.drift.adopt', detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object, value: checked.value } })
|
||
return res.status(204).end()
|
||
}
|
||
|
||
/** A `chat-field` row: put the site's value back over the hand edit (§33.2). */
|
||
async function revokeStyleField(req, res, row) {
|
||
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
|
||
await db.deleteDrift(row.id)
|
||
await db.markDirty(row.serverId)
|
||
|
||
await core.activity.log({ req, action: 'rust.perm.drift.revoke', detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object } })
|
||
return res.status(202).json({ queued: true })
|
||
}
|
||
|
||
/** Run the loop's pass now, for one server or all of them, and report what happened. */
|
||
async function syncNow(req, res) {
|
||
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
|
||
|
||
try {
|
||
if (serverId && !(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
|
||
|
||
await db.markDirty(serverId || model.FLEET)
|
||
await permSync.tick({ force: serverId })
|
||
await core.activity.log({ req, action: 'rust.perm.sync', detail: { server: serverId || 'all' } })
|
||
|
||
const state = await view.overview()
|
||
return res.json({ servers: state.servers, drift: state.drift })
|
||
} catch (err) {
|
||
return fail(res, err, 'run the sync')
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
overview,
|
||
server,
|
||
players,
|
||
catalogue,
|
||
setPolicy,
|
||
grant,
|
||
revoke,
|
||
removeException,
|
||
createGroup,
|
||
updateGroup,
|
||
deleteGroup,
|
||
setGroupPermissions,
|
||
setGroupServers,
|
||
splitGroup,
|
||
addMember,
|
||
removeMember,
|
||
clearMembers,
|
||
adoptDrift,
|
||
revokeDrift,
|
||
acceptDrift,
|
||
restoreDrift,
|
||
dismissDrift,
|
||
syncNow,
|
||
}
|