// ── Report, block and hide, for chat lines ───────────────────────────────── // // USER_SAFETY.md §4.2 (MODULE_API 1.21.0). Core owns the Reports queue, a // member's blocks and their "Hide for me" list; this module owns the chat lines // those name, and is the only one that can say what a line is, who said it, and // whether a reader may see it. Four things live here: // // REPORT_TARGETS `rust.chat`: a chat line, as core's resolver asks for it — // `snapshot` for one reader, and staff's hide for everyone // GAME_IDENTITIES `steam`: which website account a Steam id is linked to, // so a block on a linked speaker lands on their account (S12) // and a Steam-id block becomes one once the id links (S14) // feedFilter what one reader's events feed leaves out of the chat (S11) // annotate `canBlock` on each chat line, for a signed-in reader (S13) // // ── The rules, each decided by the org lead ─────────────────────────────── // // S6 Reporting, hiding and blocking all need an account. A signed-out reader // gets the feed as it is, with no `canBlock`. // S8 Staff cannot be blocked. Core refuses the block and drops a block whose // target later became staff; here a line whose speaker is linked to staff // says `canBlock: false`. // S10 A line staff hid for everyone is still staff's to see, marked. // S12 A speaker is checked by their linked ACCOUNT first, and by Steam id only // when they have none. // // A block hides what someone SAYS: chat lines. Their deaths, connects and kills // stay in the feed, because the feed is what happened on the server and a block // is not a way to rewrite that. const core = require('./core') const eventsDb = require('./model/events/events.db') const linksDb = require('./model/links/links.db') const serversDb = require('./model/servers/servers.db') const visibility = require('./model/visibility/visibility.model') const log = core.logger('safety') const CHAT = 'rust.chat' const STEAM = 'steam' function parseFrame(raw) { try { return typeof raw === 'string' ? JSON.parse(raw) : raw || {} } catch { return {} } } const ids = (list) => [...new Set((list || []).filter((v) => v != null).map(String))] /** Steam id → website account id, for the ids that are linked. */ async function ownersOf(steamIds) { const wanted = ids(steamIds) if (!wanted.length) return {} const rows = await linksDb.userIdsForSteamIds(wanted) return Object.fromEntries(rows.map((r) => [String(r.steamId), Number(r.userId)])) } /** * One chat line, for one reader, or null. * * Null is the answer for a line that is gone AND for one this reader could not * have seen — on a server whose presence is hidden from them, or hidden by staff * when they are not staff — because core answers 404 to both, and a report or a * block must never confirm who said line 4410 to somebody the feed never showed * it to (MODULE_API.md §2.4). */ async function snapshot({ targetId, viewer }) { const row = await eventsDb.chatLineById(targetId) if (!row) return null const presence = await visibility.canSeePresenceAs(viewer && viewer.userId, row.serverId) if (!presence.visible) return null if (row.hiddenAt && presence.level !== 'staff') return null const frame = parseFrame(row.raw) const steamId = row.steamId ? String(row.steamId) : null const [owners, server] = await Promise.all([ steamId ? ownersOf([steamId]) : {}, serversDb.getServer(row.serverId).catch(() => null), ]) return { author: { name: frame.name || null, userId: steamId && owners[steamId] ? owners[steamId] : null, identity: steamId ? { kind: STEAM, id: steamId } : null, }, text: typeof frame.message === 'string' ? frame.message : '', at: Number.isFinite(Number(row.t)) ? new Date(Number(row.t)).toISOString() : null, where: { server: row.serverId, serverName: server ? server.name : null, channel: frame.channel || 'Global', wipe: row.wipeId || null, }, } } /** * Staff's hide for everyone, from core's Reports queue (S3, S10). A line the * sweep has already pruned has nothing left to hide, and that is not an error: * the report is still actioned. */ async function hide({ targetId, actor }) { if (!(await eventsDb.chatLineById(targetId))) return await eventsDb.setChatHidden(targetId, true, actor && actor.id != null ? actor.id : null) } async function unhide({ targetId }) { await eventsDb.setChatHidden(targetId, false) } const REPORT_TARGETS = Object.freeze([ Object.freeze({ id: CHAT, label: 'Chat line', snapshot, hide, unhide }), ]) const GAME_IDENTITIES = Object.freeze([ Object.freeze({ kind: STEAM, label: 'Steam account', ownersOf }), ]) /** * What one reader's feed leaves out of the chat, as `events.recent` takes it: * the Steam ids whose lines they do not get, the lines they hid, and whether * staff's hidden lines are theirs to see. * * The Steam ids are S12 in one set: every id linked to an account they block, * plus their Steam-id blocks — which core answers only for ids still unlinked, * having already turned a linked one into an account block (S14) or dropped it * when that account is staff (S8). So a line is left out by its speaker's * account when there is one, and by its Steam id only when there is not. * * **A failure here shows the feed unfiltered rather than failing it.** The feed * is the page the site promises to render while everything behind it is down; * a member who sees a blocked line once, with a warning in the log, is the * smaller harm than a blank killfeed for everyone signed in. */ async function feedFilter({ userId, staff }) { const base = { showHidden: Boolean(staff), steamIds: [], ids: [] } if (userId == null) return base try { const mine = await core.safety.forViewer(userId) const blockedUsers = (mine.blockedUserIds || []).map(Number).filter((n) => Number.isInteger(n) && n > 0) const linked = blockedUsers.length ? await linksDb.steamIdsForUsers(blockedUsers) : [] const unlinked = (mine.blockedIdentities && mine.blockedIdentities[STEAM]) || [] const hidden = (mine.hidden && mine.hidden[CHAT]) || [] return { ...base, steamIds: ids([...linked, ...unlinked]), ids: [...new Set(hidden.map(Number).filter((n) => Number.isInteger(n) && n > 0))], } } catch (err) { log.warn('could not read the reader’s blocks; the feed is unfiltered for them', { error: err.message }) return base } } /** * `canBlock` on each chat line, for a signed-in reader (S13). False when the * line has no Steam id, when the speaker's linked account is the reader's own, * or when it is staff (S8). Signed-out readers get the rows untouched. * * A failure answers `canBlock: false` on every line: the menu then offers no * Block, and core would refuse a block on staff anyway. */ async function annotate(events, { userId }) { if (userId == null) return events const chat = events.filter((e) => e.kind === 'player.chat') if (!chat.length) return events let owners = {} let staff = new Set() let ok = true try { owners = await ownersOf(chat.map((e) => e.steamId)) const linked = [...new Set(Object.values(owners))] staff = new Set((linked.length ? await core.safety.staffAmong(linked) : []).map(Number)) } catch (err) { log.warn('could not work out who can be blocked; offering no block', { error: err.message }) ok = false } return events.map((e) => { if (e.kind !== 'player.chat') return e const owner = e.steamId ? owners[String(e.steamId)] : undefined const canBlock = ok && Boolean(e.steamId) && owner !== Number(userId) && !(owner != null && staff.has(owner)) return { ...e, canBlock } }) } module.exports = { CHAT, STEAM, REPORT_TARGETS, GAME_IDENTITIES, snapshot, hide, unhide, ownersOf, feedFilter, annotate }