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