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
895 lines
31 KiB
JavaScript
895 lines
31 KiB
JavaScript
// ── SQL for the permission mirror, and nothing else ───────────────────────
|
|
//
|
|
// The tables this file reads are described at length in `db/schema.sql`; what
|
|
// matters here is which of them is authoritative for what, because several look
|
|
// similar and answer completely different questions:
|
|
//
|
|
// AUTHORED the site's own record of every permission and group on every
|
|
// server (D160). Groups are `rust_permgroups` and the rows beside
|
|
// them — one group per server unless an admin shares it (D189).
|
|
// Holders are website users (`rust_perm_grants`,
|
|
// `rust_permgroup_members`, D28) or single Steam accounts
|
|
// (`rust_perm_steam_grants`, `rust_permgroup_steam_members`, D188),
|
|
// and a grant that reaches several servers may carry exceptions
|
|
// (`rust_perm_exceptions`, D190).
|
|
// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's
|
|
// store. Keyed by STEAM ID, because it records what is in the game
|
|
// and the game has never heard of a website account.
|
|
// FOUND `rust_perm_drift` — a change made in the game that waits for a
|
|
// person: every one under the `adopt` policy, and the few no policy
|
|
// can settle alone (D161, D190).
|
|
// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put
|
|
// it there.
|
|
//
|
|
// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js`
|
|
// here. Bulk writes are batched into one statement with a generated placeholder
|
|
// list rather than looped, because a fleet-wide sync writes hundreds of rows and
|
|
// a round trip each is how a boot tick becomes a second long.
|
|
|
|
const core = require('../../core')
|
|
|
|
const GROUPS = 'rust_permgroups'
|
|
const GROUP_SERVERS = 'rust_permgroup_servers'
|
|
const GROUP_PERMISSIONS = 'rust_permgroup_permissions'
|
|
const GROUP_MEMBERS = 'rust_permgroup_members'
|
|
const GROUP_STEAM_MEMBERS = 'rust_permgroup_steam_members'
|
|
const GROUP_CHAT = 'rust_permgroup_chat'
|
|
const GRANTS = 'rust_perm_grants'
|
|
const STEAM_GRANTS = 'rust_perm_steam_grants'
|
|
const EXCEPTIONS = 'rust_perm_exceptions'
|
|
const RUN_GRANTS = 'rust_perm_run_grants'
|
|
const PUSHED = 'rust_perm_pushed'
|
|
const DRIFT = 'rust_perm_drift'
|
|
const REVOCATIONS = 'rust_perm_revocations'
|
|
const SYNC = 'rust_perm_sync'
|
|
const CATALOGUE = 'rust_perm_catalogue'
|
|
const LINKS = 'rust_account_links'
|
|
const SERVERS = 'rust_servers'
|
|
const SETTINGS = 'rust_settings'
|
|
|
|
// The tables before the rebuild. Read once, by `migrateGroups`, and never again.
|
|
const OLD_GROUPS = 'rust_perm_groups'
|
|
const OLD_GROUP_PERMISSIONS = 'rust_perm_group_permissions'
|
|
const OLD_GROUP_MEMBERS = 'rust_perm_group_members'
|
|
const OLD_GROUP_CHAT = 'rust_perm_group_chat'
|
|
const MIGRATED_KEY = 'perm.groups.migrated'
|
|
|
|
/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
|
|
function placeholders(rows, width) {
|
|
return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
|
|
}
|
|
|
|
const affected = (result) => Number((result && result.affectedRows) || 0)
|
|
|
|
// ---- groups (D189) ----
|
|
|
|
const GROUP_COLUMNS = `id, name, title, \`rank\`, parent, all_servers AS allServers, source,
|
|
created_at AS createdAt, updated_at AS updatedAt`
|
|
|
|
async function listGroups() {
|
|
const rows = await core.query(`SELECT ${GROUP_COLUMNS} FROM ${GROUPS} ORDER BY \`rank\` DESC, name ASC, id ASC`)
|
|
return rows.map((row) => ({ ...row, allServers: Boolean(Number(row.allServers)) }))
|
|
}
|
|
|
|
async function getGroup(id) {
|
|
const rows = await core.query(`SELECT ${GROUP_COLUMNS} FROM ${GROUPS} WHERE id = ?`, [id])
|
|
return rows[0] ? { ...rows[0], allServers: Boolean(Number(rows[0].allServers)) } : null
|
|
}
|
|
|
|
/** Every group's server rows: `included` 1 is on, 0 is an all-servers group's exclusion. */
|
|
async function listGroupServers() {
|
|
const rows = await core.query(`SELECT group_id AS groupId, server_id AS serverId, included FROM ${GROUP_SERVERS}`)
|
|
return rows.map((row) => ({ ...row, included: Boolean(Number(row.included)) }))
|
|
}
|
|
|
|
async function insertGroup({ name, title = '', rank = 0, parent = '', allServers = false, source = 'admin' }) {
|
|
const result = await core.query(
|
|
`INSERT INTO ${GROUPS} (name, title, \`rank\`, parent, all_servers, source) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[name, title, rank, parent, allServers ? 1 : 0, source],
|
|
)
|
|
return Number(result.insertId)
|
|
}
|
|
|
|
async function updateGroup(id, { title, rank, parent }) {
|
|
await core.query(
|
|
`UPDATE ${GROUPS} SET title = ?, \`rank\` = ?, parent = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
|
[title, rank, parent, id],
|
|
)
|
|
}
|
|
|
|
async function deleteGroup(id) {
|
|
return affected(await core.query(`DELETE FROM ${GROUPS} WHERE id = ?`, [id])) > 0
|
|
}
|
|
|
|
/**
|
|
* Put a group on exactly these servers, or on all of them less `excluded`.
|
|
* Replaced whole: the form edits the set as one thing.
|
|
*/
|
|
async function setGroupServers(id, { allServers, servers = [], excluded = [] }) {
|
|
await core.query(`UPDATE ${GROUPS} SET all_servers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [allServers ? 1 : 0, id])
|
|
await core.query(`DELETE FROM ${GROUP_SERVERS} WHERE group_id = ?`, [id])
|
|
|
|
const rows = allServers ? excluded.map((s) => [s, 0]) : servers.map((s) => [s, 1])
|
|
if (!rows.length) return
|
|
|
|
await core.query(
|
|
`INSERT INTO ${GROUP_SERVERS} (group_id, server_id, included) VALUES ${placeholders(rows, 3)}`,
|
|
rows.flatMap(([serverId, included]) => [id, serverId, included]),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Take one server off a group (D190's split, and a group deleted in one game):
|
|
* an all-servers group gains an exclusion, any other loses the server's row.
|
|
*/
|
|
async function removeGroupFromServer(id, serverId) {
|
|
const group = await getGroup(id)
|
|
if (!group) return
|
|
|
|
if (group.allServers) {
|
|
await core.query(
|
|
`INSERT INTO ${GROUP_SERVERS} (group_id, server_id, included) VALUES (?, ?, 0)
|
|
ON DUPLICATE KEY UPDATE included = 0`,
|
|
[id, serverId],
|
|
)
|
|
} else {
|
|
await core.query(`DELETE FROM ${GROUP_SERVERS} WHERE group_id = ? AND server_id = ?`, [id, serverId])
|
|
}
|
|
|
|
await core.query(`UPDATE ${GROUPS} SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [id])
|
|
}
|
|
|
|
async function listGroupPermissions() {
|
|
return core.query(`SELECT group_id AS groupId, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`)
|
|
}
|
|
|
|
/** Replace a group's permission list whole. */
|
|
async function setGroupPermissions(id, permissions) {
|
|
await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_id = ?`, [id])
|
|
if (!permissions.length) return
|
|
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_PERMISSIONS} (group_id, permission) VALUES ${placeholders(permissions, 2)}`,
|
|
permissions.flatMap((permission) => [id, permission]),
|
|
)
|
|
}
|
|
|
|
async function addGroupPermission(id, permission) {
|
|
return affected(await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_PERMISSIONS} (group_id, permission) VALUES (?, ?)`,
|
|
[id, permission],
|
|
)) > 0
|
|
}
|
|
|
|
async function removeGroupPermission(id, permission) {
|
|
return affected(await core.query(
|
|
`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_id = ? AND permission = ?`,
|
|
[id, permission],
|
|
)) > 0
|
|
}
|
|
|
|
/** Every group's BetterChat style, one row per field (phase 17, D138). */
|
|
async function listGroupChat() {
|
|
return core.query(`SELECT group_id AS groupId, field, value FROM ${GROUP_CHAT} ORDER BY group_id ASC, field ASC`)
|
|
}
|
|
|
|
/** Replace a group's style whole, or remove it with `null`. */
|
|
async function setGroupChat(id, fields) {
|
|
await core.query(`DELETE FROM ${GROUP_CHAT} WHERE group_id = ?`, [id])
|
|
|
|
const entries = fields ? Object.entries(fields) : []
|
|
if (!entries.length) return
|
|
|
|
await core.query(
|
|
`INSERT INTO ${GROUP_CHAT} (group_id, field, value) VALUES ${placeholders(entries, 3)}`,
|
|
entries.flatMap(([field, value]) => [id, field, value]),
|
|
)
|
|
}
|
|
|
|
/** One field of a style, for adopting a hand edit. Returns whether the group has that field. */
|
|
async function setGroupChatField(id, field, value) {
|
|
return affected(await core.query(
|
|
`UPDATE ${GROUP_CHAT} SET value = ? WHERE group_id = ? AND field = ?`,
|
|
[value, id, field],
|
|
)) > 0
|
|
}
|
|
|
|
async function getGroupChat(id) {
|
|
const rows = await core.query(`SELECT field, value FROM ${GROUP_CHAT} WHERE group_id = ?`, [id])
|
|
return rows.length ? Object.fromEntries(rows.map((r) => [r.field, r.value])) : null
|
|
}
|
|
|
|
/**
|
|
* Members who are website accounts, with each account's linked Steam ids joined
|
|
* on — one row per (membership, Steam id), which the push and the screen both want.
|
|
*/
|
|
async function listGroupMembers() {
|
|
return core.query(
|
|
`SELECT m.group_id AS groupId, m.user_id AS userId, m.added_at AS addedAt,
|
|
u.username, l.steam_id AS steamId, p.name AS playerName
|
|
FROM ${GROUP_MEMBERS} m
|
|
JOIN users u ON u.id = m.user_id
|
|
LEFT JOIN ${LINKS} l ON l.user_id = m.user_id
|
|
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
|
ORDER BY m.group_id ASC, u.username ASC`,
|
|
)
|
|
}
|
|
|
|
async function addGroupMember(id, userId, addedBy) {
|
|
return affected(await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_id, user_id, added_by) VALUES (?, ?, ?)`,
|
|
[id, userId, addedBy],
|
|
)) > 0
|
|
}
|
|
|
|
async function removeGroupMember(id, userId) {
|
|
return affected(await core.query(`DELETE FROM ${GROUP_MEMBERS} WHERE group_id = ? AND user_id = ?`, [id, userId])) > 0
|
|
}
|
|
|
|
/** Members who are one Steam account (D188). */
|
|
async function listGroupSteamMembers() {
|
|
return core.query(
|
|
`SELECT s.group_id AS groupId, s.steam_id AS steamId, s.source, s.added_at AS addedAt, p.name AS playerName
|
|
FROM ${GROUP_STEAM_MEMBERS} s
|
|
LEFT JOIN rust_players p ON p.steam_id = s.steam_id
|
|
ORDER BY s.group_id ASC, s.steam_id ASC`,
|
|
)
|
|
}
|
|
|
|
async function addGroupSteamMember(id, steamId, { source = 'admin', addedBy = null } = {}) {
|
|
return affected(await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_STEAM_MEMBERS} (group_id, steam_id, source, added_by) VALUES (?, ?, ?, ?)`,
|
|
[id, steamId, source, addedBy],
|
|
)) > 0
|
|
}
|
|
|
|
async function removeGroupSteamMember(id, steamId) {
|
|
return affected(await core.query(
|
|
`DELETE FROM ${GROUP_STEAM_MEMBERS} WHERE group_id = ? AND steam_id = ?`,
|
|
[id, steamId],
|
|
)) > 0
|
|
}
|
|
|
|
/**
|
|
* A copy of a group — its attributes, permissions, both kinds of member and its
|
|
* style — on no server yet. D190's split: the caller puts the copy on the one
|
|
* server whose game changed, and takes that server off the original.
|
|
*/
|
|
async function copyGroup(id, source = 'split') {
|
|
const group = await getGroup(id)
|
|
if (!group) return null
|
|
|
|
const copy = await insertGroup({ name: group.name, title: group.title, rank: group.rank, parent: group.parent, source })
|
|
|
|
await core.query(
|
|
`INSERT INTO ${GROUP_PERMISSIONS} (group_id, permission) SELECT ?, permission FROM ${GROUP_PERMISSIONS} WHERE group_id = ?`,
|
|
[copy, id],
|
|
)
|
|
await core.query(
|
|
`INSERT INTO ${GROUP_MEMBERS} (group_id, user_id, added_by, added_at)
|
|
SELECT ?, user_id, added_by, added_at FROM ${GROUP_MEMBERS} WHERE group_id = ?`,
|
|
[copy, id],
|
|
)
|
|
await core.query(
|
|
`INSERT INTO ${GROUP_STEAM_MEMBERS} (group_id, steam_id, source, added_by, added_at)
|
|
SELECT ?, steam_id, source, added_by, added_at FROM ${GROUP_STEAM_MEMBERS} WHERE group_id = ?`,
|
|
[copy, id],
|
|
)
|
|
await core.query(
|
|
`INSERT INTO ${GROUP_CHAT} (group_id, field, value) SELECT ?, field, value FROM ${GROUP_CHAT} WHERE group_id = ?`,
|
|
[copy, id],
|
|
)
|
|
|
|
return copy
|
|
}
|
|
|
|
// ---- grants ----
|
|
|
|
/**
|
|
* Every grant to a website user, with the holder's accounts joined on. One row
|
|
* per (grant, linked Steam id); a grant with nothing linked still has one row.
|
|
*/
|
|
async function listGrants({ userId = null } = {}) {
|
|
return core.query(
|
|
`SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
|
|
g.granted_at AS grantedAt, u.username,
|
|
l.steam_id AS steamId, p.name AS playerName
|
|
FROM ${GRANTS} g
|
|
JOIN users u ON u.id = g.user_id
|
|
LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
|
|
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
|
${userId === null ? '' : 'WHERE g.user_id = ?'}
|
|
ORDER BY u.username ASC, g.permission ASC`,
|
|
userId === null ? [] : [userId],
|
|
)
|
|
}
|
|
|
|
async function getGrant(id) {
|
|
const rows = await core.query(
|
|
`SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`,
|
|
[id],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
/** Add a grant, or leave the one that is already there. The return says which. */
|
|
async function insertGrant({ userId, permission, scope, source, note, grantedBy }) {
|
|
const result = await core.query(
|
|
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[userId, permission, scope, source, note, grantedBy],
|
|
)
|
|
return { inserted: affected(result) > 0, id: result.insertId }
|
|
}
|
|
|
|
/** Delete a grant and the exceptions it carried, which no foreign key can reach. */
|
|
async function deleteGrant(id) {
|
|
const removed = affected(await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])) > 0
|
|
await deleteExceptionsFor('user', id)
|
|
return removed
|
|
}
|
|
|
|
/** Every grant to one Steam account (D188), with the in-game name when the site has one. */
|
|
async function listSteamGrants({ steamId = null } = {}) {
|
|
return core.query(
|
|
`SELECT g.id, g.steam_id AS steamId, g.permission, g.scope, g.source, g.note,
|
|
g.granted_at AS grantedAt, p.name AS playerName
|
|
FROM ${STEAM_GRANTS} g
|
|
LEFT JOIN rust_players p ON p.steam_id = g.steam_id
|
|
${steamId === null ? '' : 'WHERE g.steam_id = ?'}
|
|
ORDER BY g.steam_id ASC, g.permission ASC`,
|
|
steamId === null ? [] : [steamId],
|
|
)
|
|
}
|
|
|
|
async function getSteamGrant(id) {
|
|
const rows = await core.query(
|
|
`SELECT id, steam_id AS steamId, permission, scope, source FROM ${STEAM_GRANTS} WHERE id = ?`,
|
|
[id],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function insertSteamGrant({ steamId, permission, scope, source = 'admin', note = null, grantedBy = null }) {
|
|
const result = await core.query(
|
|
`INSERT IGNORE INTO ${STEAM_GRANTS} (steam_id, permission, scope, source, note, granted_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[steamId, permission, scope, source, note, grantedBy],
|
|
)
|
|
return { inserted: affected(result) > 0, id: result.insertId }
|
|
}
|
|
|
|
async function deleteSteamGrant(id) {
|
|
const removed = affected(await core.query(`DELETE FROM ${STEAM_GRANTS} WHERE id = ?`, [id])) > 0
|
|
await deleteExceptionsFor('steam', id)
|
|
return removed
|
|
}
|
|
|
|
// ---- "everywhere except here" (D190) ----
|
|
|
|
async function listExceptions() {
|
|
return core.query(
|
|
`SELECT id, holder, grant_id AS grantId, server_id AS serverId, created_by AS createdBy, created_at AS createdAt
|
|
FROM ${EXCEPTIONS}`,
|
|
)
|
|
}
|
|
|
|
async function addException({ holder, grantId, serverId, createdBy = null }) {
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${EXCEPTIONS} (holder, grant_id, server_id, created_by) VALUES (?, ?, ?, ?)`,
|
|
[holder, grantId, serverId, createdBy],
|
|
)
|
|
}
|
|
|
|
async function deleteException(id) {
|
|
return affected(await core.query(`DELETE FROM ${EXCEPTIONS} WHERE id = ?`, [id])) > 0
|
|
}
|
|
|
|
async function deleteExceptionsFor(holder, grantId) {
|
|
await core.query(`DELETE FROM ${EXCEPTIONS} WHERE holder = ? AND grant_id = ?`, [holder, grantId])
|
|
}
|
|
|
|
// ---- what events granted (phase 13b) ----
|
|
//
|
|
// `rust_perm_run_grants` is authored by `rust.kit.entitle`, never by a person,
|
|
// and it is read beside the grants above rather than merged into them (D84): the
|
|
// push unions them, and a revert deletes exactly one step's rows.
|
|
|
|
async function listRunGrants() {
|
|
return core.query(
|
|
`SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
|
|
steam_id AS steamId, permission, kit, credit
|
|
FROM ${RUN_GRANTS}`,
|
|
)
|
|
}
|
|
|
|
async function listRunGrantsForStep(runId, stepId) {
|
|
return core.query(
|
|
`SELECT user_id AS userId, server_id AS serverId, steam_id AS steamId, permission, kit, credit
|
|
FROM ${RUN_GRANTS}
|
|
WHERE run_id = ? AND step_id = ?`,
|
|
[String(runId), String(stepId)],
|
|
)
|
|
}
|
|
|
|
async function insertRunGrants(rows) {
|
|
if (!rows.length) return 0
|
|
|
|
const result = await core.query(
|
|
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
|
|
VALUES ${placeholders(rows, 9)}`,
|
|
rows.flatMap((r) => [
|
|
String(r.runId),
|
|
String(r.stepId),
|
|
String(r.idemKey || ''),
|
|
r.userId,
|
|
r.serverId,
|
|
r.steamId,
|
|
r.permission || '',
|
|
r.kit,
|
|
r.credit ? 1 : 0,
|
|
]),
|
|
)
|
|
|
|
return affected(result)
|
|
}
|
|
|
|
async function deleteRunGrantsForStep(runId, stepId) {
|
|
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
|
|
}
|
|
|
|
async function deleteRunGrantsForKey(runId, idemKey) {
|
|
if (!idemKey) return []
|
|
return deleteRunGrantsWhere('run_id = ? AND idem_key = ?', [String(runId), String(idemKey)])
|
|
}
|
|
|
|
async function deleteRunGrantsWhere(where, params) {
|
|
const found = await core.query(`SELECT DISTINCT server_id AS serverId FROM ${RUN_GRANTS} WHERE ${where}`, params)
|
|
if (!found.length) return []
|
|
|
|
await core.query(`DELETE FROM ${RUN_GRANTS} WHERE ${where}`, params)
|
|
return found.map((row) => row.serverId)
|
|
}
|
|
|
|
/**
|
|
* One website account by name, for the authoring form. Case-insensitive because
|
|
* core stores usernames in a `_ci` collation.
|
|
*/
|
|
async function findUserByUsername(username) {
|
|
const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username])
|
|
return rows[0] || null
|
|
}
|
|
|
|
/** Which website user holds which Steam account. */
|
|
async function listLinks() {
|
|
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
|
|
}
|
|
|
|
/** Links with the account's name and the in-game name, for naming subjects on the screen (D163). */
|
|
async function listLinksNamed() {
|
|
return core.query(
|
|
`SELECT l.steam_id AS steamId, l.user_id AS userId, u.username, p.name AS playerName
|
|
FROM ${LINKS} l
|
|
JOIN users u ON u.id = l.user_id
|
|
LEFT JOIN rust_players p ON p.steam_id = l.steam_id`,
|
|
)
|
|
}
|
|
|
|
/** The in-game names the site knows for these Steam ids (D163). */
|
|
async function namesFor(steamIds) {
|
|
if (!steamIds.length) return []
|
|
return core.query(
|
|
`SELECT steam_id AS steamId, name FROM rust_players WHERE steam_id IN (${steamIds.map(() => '?').join(',')})`,
|
|
steamIds,
|
|
)
|
|
}
|
|
|
|
/** Players seen on one server, for the players list's search. Newest first, bounded. */
|
|
async function searchPlayers(serverId, q, limit = 25) {
|
|
const like = `%${String(q || '').replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
|
|
|
return core.query(
|
|
`SELECT p.steam_id AS steamId, p.name AS playerName, l.user_id AS userId, u.username
|
|
FROM rust_players p
|
|
LEFT JOIN ${LINKS} l ON l.steam_id = p.steam_id
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
WHERE (p.name LIKE ? OR p.steam_id LIKE ? OR u.username LIKE ?)
|
|
AND EXISTS (SELECT 1 FROM rust_player_wipe_stats s WHERE s.steam_id = p.steam_id AND s.server_id = ?)
|
|
ORDER BY p.last_seen DESC
|
|
LIMIT ${Number(limit) || 25}`,
|
|
[like, like, like, serverId],
|
|
)
|
|
}
|
|
|
|
// ---- one person's own half of all of it (the player tier) ----
|
|
|
|
/** The groups one website user belongs to, by account membership. */
|
|
async function listGroupsForUser(userId) {
|
|
const rows = await core.query(
|
|
`SELECT g.id, g.name, g.title, g.\`rank\`, g.all_servers AS allServers, m.added_at AS addedAt
|
|
FROM ${GROUP_MEMBERS} m
|
|
JOIN ${GROUPS} g ON g.id = m.group_id
|
|
WHERE m.user_id = ?
|
|
ORDER BY g.\`rank\` DESC, g.name ASC`,
|
|
[userId],
|
|
)
|
|
return rows.map((row) => ({ ...row, allServers: Boolean(Number(row.allServers)) }))
|
|
}
|
|
|
|
/** Every pushed row naming one of these Steam ids, across every server. */
|
|
async function listPushedForSteamIds(steamIds) {
|
|
if (!steamIds.length) return []
|
|
|
|
return core.query(
|
|
`SELECT server_id AS serverId, kind, subject, object
|
|
FROM ${PUSHED}
|
|
WHERE subject IN (${steamIds.map(() => '?').join(',')})
|
|
AND kind IN ('grant', 'member')`,
|
|
steamIds,
|
|
)
|
|
}
|
|
|
|
// ---- what is actually out there ----
|
|
|
|
async function listPushed(serverId) {
|
|
return core.query(`SELECT kind, subject, object, value FROM ${PUSHED} WHERE server_id = ?`, [serverId])
|
|
}
|
|
|
|
/**
|
|
* Record rows as landed. A row with a VALUE (a `chat-field`, and a `group`'s
|
|
* title, rank and parent since protocol 13) moves it on a second landing: the
|
|
* value is what the next inventory tells a hand edit from this site's own write
|
|
* by. Every other kind has no value and is written once.
|
|
*/
|
|
async function addPushed(serverId, rows) {
|
|
if (!rows.length) return
|
|
|
|
await core.query(
|
|
`INSERT INTO ${PUSHED} (server_id, kind, subject, object, value)
|
|
VALUES ${placeholders(rows, 5)}
|
|
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
|
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.value === undefined ? null : row.value]),
|
|
)
|
|
}
|
|
|
|
async function setPushedValue(serverId, { kind, subject, object, value }) {
|
|
await addPushed(serverId, [{ kind, subject, object, value }])
|
|
}
|
|
|
|
async function removePushed(serverId, rows) {
|
|
for (const row of rows) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await core.query(
|
|
`DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
|
|
[serverId, row.kind, row.subject, row.object],
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace one server's "needs a person" list with what the latest sync found.
|
|
*
|
|
* Whole, and `first_seen` survives through the `ON DUPLICATE KEY UPDATE`. A
|
|
* `split` row is a notice rather than a difference — nothing in the game says it
|
|
* any more once it is made — so it is left alone until a person dismisses it.
|
|
*/
|
|
async function replaceDrift(serverId, rows) {
|
|
if (!rows.length) {
|
|
await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ? AND direction <> 'split'`, [serverId])
|
|
return
|
|
}
|
|
|
|
await core.query(
|
|
`INSERT INTO ${DRIFT} (server_id, kind, subject, object, detail, direction)
|
|
VALUES ${placeholders(rows, 6)}
|
|
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail), direction = VALUES(direction)`,
|
|
rows.flatMap((row) => [
|
|
serverId,
|
|
row.kind,
|
|
row.subject,
|
|
row.object,
|
|
row.detail === undefined ? null : row.detail,
|
|
row.direction || 'added',
|
|
]),
|
|
)
|
|
|
|
await core.query(
|
|
`DELETE FROM ${DRIFT}
|
|
WHERE server_id = ?
|
|
AND direction <> 'split'
|
|
AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
|
|
[serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
|
|
)
|
|
}
|
|
|
|
/** A split notice (D190). Kept until a person dismisses it. */
|
|
async function noteSplit(serverId, { group, detail }) {
|
|
await core.query(
|
|
`INSERT INTO ${DRIFT} (server_id, kind, subject, object, detail, direction)
|
|
VALUES (?, 'group', ?, '', ?, 'split')
|
|
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail), direction = 'split'`,
|
|
[serverId, group, detail === undefined ? null : detail],
|
|
)
|
|
}
|
|
|
|
async function listDrift() {
|
|
return core.query(
|
|
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object, d.detail, d.direction,
|
|
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
|
|
l.user_id AS userId, u.username, p.name AS playerName
|
|
FROM ${DRIFT} d
|
|
LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
LEFT JOIN rust_players p ON p.steam_id = d.subject
|
|
ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
|
|
)
|
|
}
|
|
|
|
async function getDrift(id) {
|
|
const rows = await core.query(
|
|
`SELECT id, server_id AS serverId, kind, subject, object, detail, direction FROM ${DRIFT} WHERE id = ?`,
|
|
[id],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function deleteDrift(id) {
|
|
await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
|
|
}
|
|
|
|
async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
[serverId, kind, subject, object, requestedBy],
|
|
)
|
|
}
|
|
|
|
async function listRevocations(serverId) {
|
|
return core.query(`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`, [serverId])
|
|
}
|
|
|
|
async function deleteRevocations(ids) {
|
|
if (!ids.length) return
|
|
await core.query(`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`, ids)
|
|
}
|
|
|
|
// ---- the state of the mirror ----
|
|
|
|
async function ensureSyncRows() {
|
|
await core.query(`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`)
|
|
}
|
|
|
|
async function listSync() {
|
|
return core.query(
|
|
`SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash,
|
|
s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId,
|
|
s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt,
|
|
s.imported_at AS importedAt, s.report, s.error
|
|
FROM ${SYNC} s
|
|
ORDER BY s.server_id ASC`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Mark servers as needing a sync. `scope` is a server id, `*`, or a list of ids.
|
|
*/
|
|
async function markDirty(scope) {
|
|
if (!scope || scope === '*') {
|
|
await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
|
|
return
|
|
}
|
|
|
|
const ids = Array.isArray(scope) ? scope : [scope]
|
|
if (!ids.length) return
|
|
|
|
await core.query(
|
|
`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id IN (${ids.map(() => '?').join(',')})`,
|
|
ids,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Record the outcome of one attempt. `dirty` is cleared unconditionally: the
|
|
* loop's real condition is the digest, recomputed every tick.
|
|
*/
|
|
async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
|
|
const okAt = state === 'ok' ? new Date() : null
|
|
|
|
await core.query(
|
|
`INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
|
|
last_attempt_at, last_ok_at, report, error, updated_at)
|
|
VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
|
|
desired_hash = VALUES(desired_hash),
|
|
synced_hash = VALUES(synced_hash),
|
|
boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
|
|
last_attempt_at = NOW(),
|
|
last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
|
|
report = VALUES(report), error = VALUES(error),
|
|
updated_at = NOW()`,
|
|
[serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
|
|
)
|
|
}
|
|
|
|
/** The first complete inventory of a server has been imported (D198). */
|
|
async function markImported(serverId) {
|
|
await core.query(`UPDATE ${SYNC} SET imported_at = COALESCE(imported_at, NOW()) WHERE server_id = ?`, [serverId])
|
|
}
|
|
|
|
/** Every server's policy for a change made in the game (D161). */
|
|
async function listPolicies() {
|
|
return core.query(`SELECT id AS serverId, perm_policy AS policy FROM ${SERVERS}`)
|
|
}
|
|
|
|
async function setPolicy(serverId, policy) {
|
|
return affected(await core.query(`UPDATE ${SERVERS} SET perm_policy = ? WHERE id = ?`, [policy, serverId])) > 0
|
|
}
|
|
|
|
// ---- the option source ----
|
|
|
|
/** What one server's plugins registered, and which plugin registered each (§0.1). */
|
|
async function putCatalogue(serverId, rows) {
|
|
await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
|
|
if (!rows.length) return
|
|
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${CATALOGUE} (server_id, permission, owner) VALUES ${placeholders(rows, 3)}`,
|
|
rows.flatMap((row) => [serverId, row.permission, row.owner || null]),
|
|
)
|
|
}
|
|
|
|
async function listCatalogue() {
|
|
return core.query(
|
|
`SELECT server_id AS serverId, permission, owner FROM ${CATALOGUE} ORDER BY permission ASC`,
|
|
)
|
|
}
|
|
|
|
// ---- the one-time copy out of the old group tables ----
|
|
|
|
/**
|
|
* Copy the groups made before the rebuild into the new tables, once.
|
|
*
|
|
* A group scoped `*` becomes a group on every server, and one scoped to a server
|
|
* becomes that server's group, so what each server receives does not change. The
|
|
* marker in `rust_settings` is what makes it once: without it, a site whose admin
|
|
* later deleted every group would have them all copied back on the next boot.
|
|
* Returns how many groups were copied.
|
|
*/
|
|
async function migrateGroups() {
|
|
const done = await core.query(`SELECT value FROM ${SETTINGS} WHERE setting_key = ?`, [MIGRATED_KEY])
|
|
if (done.length) return 0
|
|
|
|
const old = await core.query(`SELECT name, title, \`rank\`, scope FROM ${OLD_GROUPS}`)
|
|
|
|
// A copy that failed halfway is finished, not repeated: a group already
|
|
// migrated under its name is skipped.
|
|
const already = new Set(
|
|
(await core.query(`SELECT name FROM ${GROUPS} WHERE source = 'migrated'`)).map((row) => row.name),
|
|
)
|
|
|
|
for (const group of old) {
|
|
if (already.has(group.name)) continue
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const id = await insertGroup({
|
|
name: group.name,
|
|
title: group.title || '',
|
|
rank: Number(group.rank) || 0,
|
|
allServers: group.scope === '*',
|
|
source: 'migrated',
|
|
})
|
|
|
|
const params = [id, group.name]
|
|
/* eslint-disable no-await-in-loop */
|
|
if (group.scope !== '*') {
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_SERVERS} (group_id, server_id, included)
|
|
SELECT ?, id, 1 FROM ${SERVERS} WHERE id = ?`,
|
|
[id, group.scope],
|
|
)
|
|
}
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_PERMISSIONS} (group_id, permission)
|
|
SELECT ?, permission FROM ${OLD_GROUP_PERMISSIONS} WHERE group_name = ?`,
|
|
params,
|
|
)
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_id, user_id, added_by, added_at)
|
|
SELECT ?, user_id, added_by, added_at FROM ${OLD_GROUP_MEMBERS} WHERE group_name = ?`,
|
|
params,
|
|
)
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${GROUP_CHAT} (group_id, field, value)
|
|
SELECT ?, field, value FROM ${OLD_GROUP_CHAT} WHERE group_name = ?`,
|
|
params,
|
|
)
|
|
/* eslint-enable no-await-in-loop */
|
|
}
|
|
|
|
await core.query(
|
|
`INSERT IGNORE INTO ${SETTINGS} (setting_key, value) VALUES (?, ?)`,
|
|
[MIGRATED_KEY, String(old.length)],
|
|
)
|
|
|
|
return old.length
|
|
}
|
|
|
|
module.exports = {
|
|
GROUPS,
|
|
GRANTS,
|
|
STEAM_GRANTS,
|
|
RUN_GRANTS,
|
|
PUSHED,
|
|
DRIFT,
|
|
listGroups,
|
|
getGroup,
|
|
listGroupServers,
|
|
insertGroup,
|
|
updateGroup,
|
|
deleteGroup,
|
|
setGroupServers,
|
|
removeGroupFromServer,
|
|
listGroupPermissions,
|
|
setGroupPermissions,
|
|
addGroupPermission,
|
|
removeGroupPermission,
|
|
listGroupChat,
|
|
setGroupChat,
|
|
setGroupChatField,
|
|
getGroupChat,
|
|
listGroupMembers,
|
|
addGroupMember,
|
|
removeGroupMember,
|
|
listGroupSteamMembers,
|
|
addGroupSteamMember,
|
|
removeGroupSteamMember,
|
|
copyGroup,
|
|
listGrants,
|
|
getGrant,
|
|
insertGrant,
|
|
deleteGrant,
|
|
listSteamGrants,
|
|
getSteamGrant,
|
|
insertSteamGrant,
|
|
deleteSteamGrant,
|
|
listExceptions,
|
|
addException,
|
|
deleteException,
|
|
deleteExceptionsFor,
|
|
listRunGrants,
|
|
listRunGrantsForStep,
|
|
insertRunGrants,
|
|
deleteRunGrantsForStep,
|
|
deleteRunGrantsForKey,
|
|
findUserByUsername,
|
|
listLinks,
|
|
listLinksNamed,
|
|
namesFor,
|
|
searchPlayers,
|
|
listGroupsForUser,
|
|
listPushedForSteamIds,
|
|
listPushed,
|
|
addPushed,
|
|
setPushedValue,
|
|
removePushed,
|
|
replaceDrift,
|
|
noteSplit,
|
|
listDrift,
|
|
getDrift,
|
|
deleteDrift,
|
|
queueRevocation,
|
|
listRevocations,
|
|
deleteRevocations,
|
|
ensureSyncRows,
|
|
listSync,
|
|
markDirty,
|
|
putSyncResult,
|
|
markImported,
|
|
listPolicies,
|
|
setPolicy,
|
|
putCatalogue,
|
|
listCatalogue,
|
|
migrateGroups,
|
|
}
|