// ── Block and "Hide for me" (USER_SAFETY.md §1, §4.1) ────────────────────── // // A block lives on the blocker's ACCOUNT (S2), so it applies on the website and // in the app, on every device. The rules that shape this file, each decided by // the org lead: // // S12 A block names the author's WEBSITE ACCOUNT whenever there is one. A // forum author always has one; a chat speaker has one when their game // identity is linked. Only an unlinked game identity — most people in a // Rust chat feed — is blocked as itself, a Steam id. // S14 When that identity is later linked, the game block becomes an account // block, the next time the member's blocks are read. From then on it hides // the account's forum posts too. // S8 Staff (admin, editor, moderator) cannot be blocked. Blocking one is // refused. A block whose target LATER became staff — promoted, or a game // block whose identity linked to a staff account — is kept but stops // applying, and the Blocked page says why. // S9 A block does nothing to the blocked person. They are not told. // // "Hide for me" is one item gone from one member's view; it files no report and // changes nothing about the item (§1). // // Filtering happens on the server for every reader-specific read: core's forum // here, and a module's per-viewer feeds through `ctx.safety.forViewer` (S11). const db = require('./userSafety.db') const targets = require('./targets') const registries = require('../../modules/registries') const { STAFF_ROLES } = require('../../utils/displayAuthor') const log = require('../../utils/logger')('safety') const isStaffRole = (role) => STAFF_ROLES.includes(role) const refuse = (status, code, error) => ({ ok: false, status, code, error }) /** * This member's blocks, as they stand NOW. * * S14 runs here: every game block is checked against its module's links, and * one whose identity is linked is rewritten as an account block before the list * is answered. Reading is where it happens because a link is made in a module, * which has no reason to tell core, and a block that waited for a notification * would be a block that silently missed one. * * A module that does not answer leaves its game blocks as they are: they still * apply to the Steam id, which is the block the member made. */ async function currentBlocks(userId) { let rows = await db.blocksFor(userId) const game = rows.filter((r) => r.blocked_user_id == null && r.module_id) if (game.length) { const groups = new Map() for (const r of game) { const key = `${r.module_id}:${r.identity_kind}` if (!groups.has(key)) groups.set(key, []) groups.get(key).push(r) } let changed = false for (const group of groups.values()) { const ident = registries.gameIdentity(group[0].module_id, group[0].identity_kind) if (!ident) continue let owners = {} try { owners = (await targets.withTimeout( () => ident.ownersOf(group.map((r) => r.external_id)), targets.MODULE_TIMEOUT_MS, )) || {} } catch (err) { log.warn('game identity lookup failed; game blocks left as they are', { module: ident.owner, kind: ident.kind, message: err.message, }) continue } for (const r of group) { const owner = Number(owners[r.external_id]) if (!Number.isInteger(owner) || owner <= 0) continue // The member blocked a Steam id that turned out to be their own. A block // on yourself hides nothing worth hiding, so it is simply removed. if (owner === Number(userId)) await db.deleteBlock(userId, r.id) else await db.convertToAccountBlock(r.id, owner) changed = true } } if (changed) rows = await db.blocksFor(userId) } return rows.map((r) => ({ ...r, applies: r.blocked_user_id == null ? true : !isStaffRole(r.blocked_role), })) } /** One block as the member sees it on Account → Blocked. */ function publicBlock(row) { const account = row.blocked_user_id != null const ident = account ? null : registries.gameIdentity(row.module_id, row.identity_kind) return { id: row.id, kind: account ? 'account' : 'game', name: account ? (row.blocked_username || row.display_name) : row.display_name, identity: account ? null : { module: row.module_id, kind: row.identity_kind, label: ident ? ident.label : row.identity_kind, id: row.external_id, }, applies: row.applies, reason: row.applies ? null : 'staff', createdAt: row.created_at, } } async function listBlocks(actor) { return (await currentBlocks(actor.id)).map(publicBlock) } /** Block an account, after S8's check. */ async function blockAccount(actor, userId) { if (Number(userId) === Number(actor.id)) return refuse(400, 'self', 'You can’t block yourself.') const target = await db.userById(userId) if (!target) return refuse(404, 'not_found', 'Not found') if (isStaffRole(target.role)) return refuse(403, 'staff', 'You can’t block a staff member.') const id = await db.insertAccountBlock({ blockerUserId: actor.id, blockedUserId: target.id, displayName: target.username, }) if (id == null) return refuse(409, 'already_blocked', `You have already blocked ${target.username}.`) return { ok: true, block: { id, kind: 'account', name: target.username, identity: null, applies: true, reason: null } } } /** * Block the author of one item (S12). * * The client names the item, never the person: the server reads who wrote it, * through the same resolver a report uses, so a member can only block someone * whose words they could see, and the block lands on the account when there is * one. */ async function blockAuthorOf({ actor, targetType, targetId }) { if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target') const item = await targets.resolve({ targetType, targetId, viewer: actor }) if (!item) return refuse(404, 'not_found', 'Not found') const { author } = item if (author.userId != null) return blockAccount(actor, author.userId) // No account on the item. A forum item with no account is a deleted one, and // there is nobody left to block. if (!item.module || !author.identity) { return refuse(400, 'no_author', 'That account no longer exists.') } // A game identity. S12: its account first, when it has one. const ident = registries.gameIdentity(item.module, author.identity.kind) if (!ident) return refuse(400, 'no_author', 'This player can’t be blocked.') let owners try { owners = (await targets.withTimeout(() => ident.ownersOf([author.identity.id]), targets.MODULE_TIMEOUT_MS)) || {} } catch (err) { log.error('game identity lookup failed', { module: ident.owner, kind: ident.kind, message: err.message }) return refuse(503, 'module_unavailable', 'The game module did not answer. Try again in a moment.') } const owner = Number(owners[author.identity.id]) if (Number.isInteger(owner) && owner > 0) return blockAccount(actor, owner) const id = await db.insertGameBlock({ blockerUserId: actor.id, moduleId: item.module, kind: author.identity.kind, externalId: author.identity.id, displayName: author.name, }) if (id == null) return refuse(409, 'already_blocked', `You have already blocked ${author.name || 'this player'}.`) return { ok: true, block: { id, kind: 'game', name: author.name, identity: { module: item.module, kind: ident.kind, label: ident.label, id: author.identity.id }, applies: true, reason: null, }, } } async function unblock(actor, id) { return (await db.deleteBlock(actor.id, id)) ? { ok: true } : refuse(404, 'not_found', 'Not found') } // ── hide for me ──────────────────────────────────────────────────────────── /** * Hide one item for this member. The type must be one a member can hide; the * item is not looked up — see `user_hidden_items` in schema.sql for why a row for * an id that does not exist is harmless and an existence check is not. */ async function hide(actor, targetType, targetId) { if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target') await db.insertHidden(actor.id, targetType, targetId) return { ok: true } } async function unhide(actor, targetType, targetId) { if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target') await db.deleteHidden(actor.id, targetType, targetId) return { ok: true } } /** Account → Blocked's "Show them all again". */ async function clearHidden(actor) { return db.clearHidden(actor.id) } async function listHidden(actor) { return (await db.hiddenFor(actor.id)).map((r) => ({ targetType: r.target_type, targetId: Number(r.target_id), createdAt: r.created_at, })) } // ── what one reader's reads leave out ────────────────────────────────────── const EMPTY_FILTER = Object.freeze({ blockedUserIds: new Set(), hidden: Object.freeze({ team_forum_thread: new Set(), team_forum_post: new Set() }), }) /** * What core's forum leaves out for this reader: the accounts they block (that * still apply) and the threads and posts they hid. */ async function forumFilter(userId) { if (!userId) return EMPTY_FILTER const [blocks, hidden] = await Promise.all([ currentBlocks(userId), db.hiddenFor(userId, targets.FORUM_HIDEABLE), ]) const filter = { blockedUserIds: new Set(blocks.filter((b) => b.applies && b.blocked_user_id != null).map((b) => Number(b.blocked_user_id))), hidden: { team_forum_thread: new Set(), team_forum_post: new Set() }, } for (const h of hidden) filter.hidden[h.target_type].add(Number(h.target_id)) return filter } /** * `ctx.safety.forViewer` (API 1.21.0, S11): what one reader has blocked and * hidden, for the calling module. * * `blockedUserIds` — accounts, every module's concern: a speaker linked to one * is hidden whichever module's feed they speak in. `blockedIdentities` — this * module's own unlinked game blocks, by kind. `hidden` — this reader's hides on * this module's report targets, by type. Ids are answered as arrays of the type * the module gave them (numbers for target ids, strings for identities). */ async function forModuleViewer(moduleId, userId) { const empty = { blockedUserIds: [], blockedIdentities: {}, hidden: {} } if (!userId) return empty const types = registries.allReportTargets().filter((t) => t.owner === moduleId).map((t) => t.id) const [blocks, hidden] = await Promise.all([currentBlocks(userId), db.hiddenFor(userId, types)]) const out = { blockedUserIds: [], blockedIdentities: {}, hidden: {} } for (const b of blocks) { if (!b.applies) continue if (b.blocked_user_id != null) out.blockedUserIds.push(Number(b.blocked_user_id)) else if (b.module_id === moduleId) { if (!out.blockedIdentities[b.identity_kind]) out.blockedIdentities[b.identity_kind] = [] out.blockedIdentities[b.identity_kind].push(String(b.external_id)) } } for (const t of types) out.hidden[t] = [] for (const h of hidden) out.hidden[h.target_type].push(Number(h.target_id)) return out } /** * The members whose blocks cover this author, for a notification about something * the author wrote: a forum post is pushed, mailed and put in the inbox of every * member of its Team, and a member who blocks the author must not be told about it * (the forum itself already leaves the post out for them). * * S8 applies here too: a staff author is blocked by nobody, so this answers empty * and the post reaches everyone. Answers account blocks only. A game block on the * author's Steam id that S14 has not yet rewritten counts from the next time its * owner reads the forum or their blocks, which is when the rewrite happens. */ async function blockersOf(authorUserId) { const id = Number(authorUserId) if (!Number.isInteger(id) || id <= 0) return [] const author = await db.userById(id) if (!author || isStaffRole(author.role)) return [] return db.blockersOf(id) } /** `ctx.safety.staffAmong` (S13): which of these accounts are staff. */ async function staffAmong(userIds) { const ids = [...new Set((Array.isArray(userIds) ? userIds : []) .map(Number) .filter((n) => Number.isInteger(n) && n > 0))] .slice(0, 500) return db.staffAmong(ids, STAFF_ROLES) } module.exports = { currentBlocks, listBlocks, blockAuthorOf, blockAccount, unblock, hide, unhide, listHidden, clearHidden, forumFilter, forModuleViewer, blockersOf, staffAmong, publicBlock, }