Files
Module-Rust/server/safety.js
wtclaude dd9b8b2711
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
feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
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
2026-10-30 09:04:08 -05:00

194 lines
7.9 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.

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