feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
Some checks failed
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 53s
PR Checks / server-tests (pull_request) Failing after -17s

USER_SAFETY.md step 3, the module half of report, block and hide.

- server/safety.js registers the rust.chat report target (snapshot for one
  reader, null when they could not have seen the line; staff's hide and
  unhide) and the steam game identity (ownersOf from rust_account_links).
- The events feed filters chat per reader, in the query: lines from a
  blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the
  lines they hid. Signed-in readers get canBlock on each chat line (false for
  their own and for staff-linked players). Lines staff hid for everyone are
  left out for everyone but staff, who get them with hidden: true.
- rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS).
- POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged.
- The feed's chat lines get a menu: Report, Hide for me, Block player,
  and for staff Hide for everyone / Show to everyone again.
- coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge;
  routes.manifest.json and swagger-fragment.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-30 09:04:08 -05:00
parent 1fb685fa0f
commit dd9b8b2711
24 changed files with 1182 additions and 25 deletions

193
server/safety.js Normal file
View File

@@ -0,0 +1,193 @@
// ── 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 }