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
194 lines
7.9 KiB
JavaScript
194 lines
7.9 KiB
JavaScript
// ── 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 }
|