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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,18 @@ async function userIdsForSteamIds(steamIds) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other direction: every Steam id these website accounts hold. A chat line
|
||||
* is filed under a Steam id, so a block on an account (USER_SAFETY.md S12) hides
|
||||
* the lines of every Steam id that account links.
|
||||
*/
|
||||
async function steamIdsForUsers(userIds) {
|
||||
if (!userIds.length) return []
|
||||
const marks = userIds.map(() => '?').join(', ')
|
||||
const rows = await core.query(`SELECT steam_id AS steamId FROM ${LINKS} WHERE user_id IN (${marks})`, userIds)
|
||||
return rows.map((r) => String(r.steamId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked players of one server: every linked Steam account that has played
|
||||
* there, in any wipe (PLAN_REDESIGNS.md §2.5, the kit weekend's `linked`).
|
||||
@@ -217,5 +229,6 @@ module.exports = {
|
||||
removeBySteamId,
|
||||
statsForSteamId,
|
||||
userIdsForSteamIds,
|
||||
steamIdsForUsers,
|
||||
linkedOnServer,
|
||||
}
|
||||
|
||||
@@ -103,17 +103,31 @@ async function viewerLevel(req) {
|
||||
* decided the rung: a banned account is nobody, whatever its token names.
|
||||
*/
|
||||
async function viewer(req) {
|
||||
const nobody = { level: 'public', userId: null }
|
||||
try {
|
||||
const claimed = req.user || core.auth.getUserFromRequest(req)
|
||||
if (!claimed || claimed.id == null) return nobody
|
||||
if (!claimed || claimed.id == null) return { level: 'public', userId: null }
|
||||
return await viewerById(claimed.id)
|
||||
} catch (err) {
|
||||
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
|
||||
return { level: 'public', userId: null }
|
||||
}
|
||||
}
|
||||
|
||||
const user = await core.users.getById(claimed.id)
|
||||
/**
|
||||
* The same re-read, for a caller that has an account id and no request: core's
|
||||
* report-target `snapshot`, which is handed `viewer.userId` (USER_SAFETY.md
|
||||
* §4.2). The role core passes beside it is the session's; this reads the row.
|
||||
*/
|
||||
async function viewerById(userId) {
|
||||
const nobody = { level: 'public', userId: null }
|
||||
if (userId == null) return nobody
|
||||
try {
|
||||
const user = await core.users.getById(userId)
|
||||
if (!user) return nobody
|
||||
if (user.status && user.status !== 'active') return nobody
|
||||
|
||||
const level = user.role === 'admin' || user.role === 'moderator' ? 'staff' : 'signed_in'
|
||||
return { level, userId: user.id != null ? user.id : claimed.id }
|
||||
return { level, userId: user.id != null ? user.id : userId }
|
||||
} catch (err) {
|
||||
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
|
||||
return nobody
|
||||
@@ -154,11 +168,22 @@ async function presenceFor(serverId) {
|
||||
*/
|
||||
async function canSeePresence(req, serverId) {
|
||||
try {
|
||||
const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
|
||||
return { visible: meets(level, required), level, required }
|
||||
const [who, required] = await Promise.all([viewer(req), presenceFor(serverId)])
|
||||
return { visible: meets(who.level, required), level: who.level, required, userId: who.userId }
|
||||
} catch (err) {
|
||||
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
|
||||
return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
|
||||
return { visible: false, level: 'public', required: DEFAULT_PRESENCE, userId: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** `canSeePresence` for an account id rather than a request (a report's snapshot). */
|
||||
async function canSeePresenceAs(userId, serverId) {
|
||||
try {
|
||||
const [who, required] = await Promise.all([viewerById(userId), presenceFor(serverId)])
|
||||
return { visible: meets(who.level, required), level: who.level, required, userId: who.userId }
|
||||
} catch (err) {
|
||||
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
|
||||
return { visible: false, level: 'public', required: DEFAULT_PRESENCE, userId: null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,9 +339,11 @@ module.exports = {
|
||||
normalise,
|
||||
viewerLevel,
|
||||
viewer,
|
||||
viewerById,
|
||||
fleetPresence,
|
||||
presenceFor,
|
||||
canSeePresence,
|
||||
canSeePresenceAs,
|
||||
describe,
|
||||
update,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user