Files
website/server/src/model/safety/userSafety.model.js
wtclaude 673701c91b
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m14s
PR Checks / server-tests (pull_request) Successful in 6m4s
PR Checks / bot-tests (pull_request) Successful in 56s
fix(safety): team notifications skip members who block the author
#233 left this open: the forum hid a blocked author's posts from a
member, but the member was still pushed, put in the inbox and emailed
about them.

- teamNotify.forumPost excludes every member who blocks the post's
  author, or the member who started the thread (the forum 404s that
  thread for them). One recipient list, so push, the inbox and email
  skip the same people. userSafety.blockersOf answers it, and a staff
  author is blocked by nobody (S8). A failed lookup notifies without it.
- The daily digest applies each recipient's forumFilter at send time:
  blocked authors, blocked thread starters, hidden threads and posts.
  digestPostsSince now also returns the thread's created_by.
- A reply's notify payload carries threadAuthorUserId.

The Team activity feed needed nothing: forum posts are never written to
it (membership, renames and module game events only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-31 00:23:18 -05:00

327 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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