// ── 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, }