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

View File

@@ -312,13 +312,14 @@ async function pruneEvents(days) {
* every time; `events.model.js` builds the list from the catalogue's allowlist
* and an empty list answers with no rows rather than with everything.
*/
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50, chat = {} }) {
if (!Array.isArray(kinds) || kinds.length === 0) return []
const holes = kinds.map(() => '?').join(', ')
const params = [serverId, ...kinds]
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw,
hidden_at AS hiddenAt
FROM ${EVENTS}
WHERE server_id = ? AND kind IN (${holes})`
@@ -327,12 +328,53 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
params.push(wipeId)
}
// What this reader does not get of the chat (USER_SAFETY.md §4.2), left out in
// the query rather than after it, so a page of 100 is still 100 lines long.
// Only `player.chat` rows: a block hides what someone SAYS, not that they died.
if (!chat.showHidden) sql += ` AND NOT (kind = 'player.chat' AND hidden_at IS NOT NULL)`
if (chat.steamIds && chat.steamIds.length) {
sql += ` AND NOT (kind = 'player.chat' AND steam_id IN (${chat.steamIds.map(() => '?').join(', ')}))`
params.push(...chat.steamIds)
}
if (chat.ids && chat.ids.length) {
sql += ` AND NOT (kind = 'player.chat' AND id IN (${chat.ids.map(() => '?').join(', ')}))`
params.push(...chat.ids)
}
sql += ' ORDER BY id DESC LIMIT ?'
params.push(limit)
return core.query(sql, params)
}
/** One chat line by id, or null: the report target `rust.chat` (USER_SAFETY.md §4.2). */
async function chatLineById(id) {
const rows = await core.query(
`SELECT id, server_id AS serverId, wipe_id AS wipeId, t, steam_id AS steamId, raw,
hidden_at AS hiddenAt
FROM ${EVENTS}
WHERE id = ? AND kind = 'player.chat'`,
[id],
)
return rows[0] || null
}
/**
* Staff's hide for everyone, or its undo. A line already hidden keeps its first
* hide's time and author: the second click of two changes nothing. Whether the
* line exists is the caller's to ask first — `affectedRows` on an UPDATE that
* changes nothing depends on a driver flag.
*/
async function setChatHidden(id, hidden, by = null) {
await core.query(
hidden
? `UPDATE ${EVENTS} SET hidden_at = COALESCE(hidden_at, CURRENT_TIMESTAMP), hidden_by = COALESCE(hidden_by, ?)
WHERE id = ? AND kind = 'player.chat'`
: `UPDATE ${EVENTS} SET hidden_at = NULL, hidden_by = NULL WHERE id = ? AND kind = 'player.chat'`,
hidden ? [by, id] : [id],
)
}
/**
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
*
@@ -473,6 +515,8 @@ module.exports = {
replacePresence,
pruneEvents,
recentEvents,
chatLineById,
setChatHidden,
leaderboard,
listWipes,
presenceFor,

View File

@@ -59,18 +59,27 @@ function parseKinds(raw) {
* refused: naming it in an error would confirm the kind exists, which is a small
* thing to leak and a free one to avoid.
*/
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit, chat = {} }) {
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
// Every requested kind was refused. Answering with an empty list is right —
// the events they asked for are, as far as they are concerned, not there.
if (kinds.length === 0) return []
// `chat` is this reader's part of the chat (`safety.js`, USER_SAFETY.md §4.2):
// the Steam ids whose lines they do not get, the lines they hid, and whether a
// line staff hid for everyone is still theirs to see. Its default is the
// narrow one — a hidden line is left out unless a caller says otherwise.
const rows = await db.recentEvents({
serverId,
kinds,
wipeId,
limit: boundedLimit(limit),
chat: {
showHidden: Boolean(chat.showHidden),
steamIds: Array.isArray(chat.steamIds) ? chat.steamIds : [],
ids: Array.isArray(chat.ids) ? chat.ids : [],
},
})
const shaped = rows.map(shape)
@@ -170,6 +179,9 @@ function shape(row) {
wipeId: row.wipeId || null,
steamId: row.steamId || null,
frame,
// Staff hid this line for everyone (S10). Only staff's reads ever hold one,
// and they get it marked so the hide can be checked and undone.
...(row.hiddenAt ? { hidden: true } : {}),
}
}