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:
@@ -175,6 +175,18 @@ module.exports = {
|
||||
pushActivity: (items) => need().teams.activity.push(items),
|
||||
},
|
||||
|
||||
// Report, block and hide (MODULE_API.md §2.3, 1.21.0; USER_SAFETY.md S11, S13).
|
||||
// Read only, both of them. `forViewer(userId)` is what one reader has blocked
|
||||
// and hidden that touches this module — the accounts they block (already
|
||||
// checked against staff, S8, and with linked Steam blocks already turned into
|
||||
// account blocks, S14), this module's own unlinked Steam blocks, and their hides
|
||||
// on `rust.chat`. `staffAmong(userIds)` is which accounts are staff, for a chat
|
||||
// line's `canBlock`. Wrapped as calls, like `emit`. See `safety.js`.
|
||||
safety: {
|
||||
forViewer: (userId) => need().safety.forViewer(userId),
|
||||
staffAmong: (userIds) => need().safety.staffAmong(userIds),
|
||||
},
|
||||
|
||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||
// only correct way to find a file you shipped, because the working directory is
|
||||
// core's and the module's location is the loader's business.
|
||||
|
||||
@@ -246,6 +246,18 @@ CREATE TABLE IF NOT EXISTS rust_events (
|
||||
KEY idx_rust_events_created (created_at)
|
||||
);
|
||||
|
||||
-- Staff's "Hide for everyone" on a chat line (USER_SAFETY.md §4.2, S10). Set from
|
||||
-- core's Reports queue or from the line's own menu, and cleared from that menu.
|
||||
-- A hidden line is left out of the feed for everybody but staff, who still get it,
|
||||
-- marked, so a hide can be checked and undone where it lives. The row is kept, and
|
||||
-- the retention sweep prunes it like any other.
|
||||
--
|
||||
-- `hidden_by` is the staff account's id, with no foreign key: the hide stays a
|
||||
-- hide when the account that made it is deleted, and nothing reads it back as a
|
||||
-- person. ALTER rather than columns above, so an existing install gets them too.
|
||||
ALTER TABLE rust_events ADD COLUMN IF NOT EXISTS hidden_at DATETIME NULL;
|
||||
ALTER TABLE rust_events ADD COLUMN IF NOT EXISTS hidden_by INT NULL;
|
||||
|
||||
|
||||
-- ── Who is on right now ───────────────────────────────────────────────────
|
||||
--
|
||||
|
||||
@@ -175,6 +175,16 @@ module.exports = function register(ctx, api) {
|
||||
// deletion, and never waits on a game.
|
||||
api.onUserDeleting(links.unlinkForDeletion)
|
||||
|
||||
// Report, block and hide (MODULE_API 1.21.0; USER_SAFETY.md §4.2). A chat line
|
||||
// is a `rust.chat` report target: core files the report and keeps the queue,
|
||||
// and asks this module what a line is, for one reader, and to hide it for
|
||||
// everyone. A speaker is known by their Steam id, and `steam` says which
|
||||
// website account one is linked to, so a block lands on the account first
|
||||
// (S12). The feed's own filtering and `canBlock` are in the events route.
|
||||
const safety = require('./safety') // eslint-disable-line global-require
|
||||
api.registerReportTargets(safety.REPORT_TARGETS)
|
||||
api.registerGameIdentities(safety.GAME_IDENTITIES)
|
||||
|
||||
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
|
||||
// demo-player's Steam link, written by this module from its part of the
|
||||
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
|
||||
@@ -255,6 +265,7 @@ module.exports = function register(ctx, api) {
|
||||
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length + eventRaidableBases.BUDGETS.length,
|
||||
announceLeg: eventRewards.LEG.leg,
|
||||
commands: commands.map((cmd) => cmd.name).join(' '),
|
||||
reportTargets: safety.REPORT_TARGETS.map((t) => t.id).join(' '),
|
||||
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length + eventRaidableBases.OPTION_SOURCES.length,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('../../model/servers/servers.db')
|
||||
const eventsDb = require('../../model/events/events.db')
|
||||
const mapImages = require('../../mapImages')
|
||||
const nextWipe = require('../../model/servers/nextWipe')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
@@ -379,7 +380,40 @@ async function putVoice(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff's hide for everyone on one chat line, and its undo, from the line's own
|
||||
* menu in the feed (USER_SAFETY.md §1, S10). Core's Reports queue hides through
|
||||
* `safety.js`'s registered `hide`; this is the same flag set where the line
|
||||
* lives, and the only place it is cleared, since staff still see a hidden line
|
||||
* there, marked.
|
||||
*/
|
||||
function chatHidden(hidden) {
|
||||
return async function setChatHidden(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const line = await eventsDb.chatLineById(id)
|
||||
if (!line) return res.status(404).json({ message: 'No such chat line' })
|
||||
|
||||
await eventsDb.setChatHidden(id, hidden, req.user ? req.user.id : null)
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: hidden ? 'rust.chat.hide' : 'rust.chat.unhide',
|
||||
detail: { line: id, server: line.serverId, steamId: line.steamId || null },
|
||||
})
|
||||
return res.status(204).end()
|
||||
} catch (err) {
|
||||
log.error('failed to change a chat line', { line: id, hidden, error: err.message })
|
||||
return res.status(500).json({ message: hidden ? 'Failed to hide the line' : 'Failed to show the line again' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideChat = chatHidden(true)
|
||||
const unhideChat = chatHidden(false)
|
||||
|
||||
module.exports = {
|
||||
hideChat,
|
||||
unhideChat,
|
||||
scheduleFrom,
|
||||
listServers,
|
||||
putServer,
|
||||
|
||||
@@ -123,6 +123,39 @@ adminRustRouter.post(
|
||||
admin.testServer,
|
||||
)
|
||||
|
||||
// ── Hide a chat line for everyone (USER_SAFETY.md §1, S10) ────────────────
|
||||
//
|
||||
// From the line's own menu in the feed. `admin` and `moderator`, the two roles
|
||||
// core's Reports queue is open to — an editor writes content and moderates none.
|
||||
|
||||
adminRustRouter.post(
|
||||
'/chat/:id/hide',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Hide a chat line for everyone'
|
||||
// #swagger.description = 'Staff’s hide for everyone (USER_SAFETY.md S10): the line leaves the public feed for everybody but staff, who still get it with `hidden: true`. The same flag core’s Reports queue sets through the `rust.chat` report target. Hiding a hidden line changes nothing. Recorded in the activity log. Admins and moderators.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The chat line’s event id', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[204] = { description: 'Hidden' } */
|
||||
/* #swagger.responses[404] = { description: 'No such chat line' } */
|
||||
requireRole('admin', 'moderator'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
admin.hideChat,
|
||||
)
|
||||
|
||||
adminRustRouter.post(
|
||||
'/chat/:id/unhide',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Show a hidden chat line again'
|
||||
// #swagger.description = 'Undoes a hide for everyone, whether it was made here or from the Reports queue. Recorded in the activity log. Admins and moderators.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The chat line’s event id', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[204] = { description: 'Shown again' } */
|
||||
/* #swagger.responses[404] = { description: 'No such chat line' } */
|
||||
requireRole('admin', 'moderator'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
admin.unhideChat,
|
||||
)
|
||||
|
||||
// ── The map's picture (phase 14) ──────────────────────────────────────────
|
||||
|
||||
adminRustRouter.post(
|
||||
|
||||
@@ -15,6 +15,7 @@ const clans = require('../../model/clans/clans.model')
|
||||
const dangerousTreasures = require('../../dangerousTreasures')
|
||||
const guardedCrate = require('../../guardedCrate')
|
||||
const raidableBases = require('../../raidableBases')
|
||||
const safety = require('../../safety')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const events = require('../../model/events/events.model')
|
||||
const map = require('../../model/map/map.model')
|
||||
@@ -90,20 +91,30 @@ async function getServer(req, res) {
|
||||
*
|
||||
* `staff` is the same re-read rung, and reaches only what a world event carries
|
||||
* (protocol 14, D353): a Steam id and a position. It never widens the kinds.
|
||||
*
|
||||
* The chat is read for THIS reader too (USER_SAFETY.md S11): lines whose speaker
|
||||
* they block or that they hid are left out, a line staff hid for everyone is
|
||||
* left out unless they are staff, and a signed-in reader gets `canBlock` on each
|
||||
* line (S13). That is why this route was already `perViewer` and must stay so.
|
||||
*/
|
||||
async function listEvents(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
const reader = { userId: presence.userId, staff: presence.level === 'staff' }
|
||||
// Below the presence audience there is no chat to filter or mark.
|
||||
const chat = presence.visible ? await safety.feedFilter(reader) : {}
|
||||
const rows = await events.recent({
|
||||
serverId: req.params.id,
|
||||
presence: presence.visible,
|
||||
staff: reader.staff,
|
||||
kind: req.query.kind,
|
||||
wipeId: req.query.wipe || null,
|
||||
limit: req.query.limit,
|
||||
chat,
|
||||
})
|
||||
perViewer(res)
|
||||
res.json({
|
||||
events: await events.recent({
|
||||
serverId: req.params.id,
|
||||
presence: presence.visible,
|
||||
staff: presence.level === 'staff',
|
||||
kind: req.query.kind,
|
||||
wipeId: req.query.wipe || null,
|
||||
limit: req.query.limit,
|
||||
}),
|
||||
events: presence.visible ? await safety.annotate(rows, reader) : rows,
|
||||
presenceHidden: !presence.visible,
|
||||
presenceAudience: presence.required,
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
||||
'/servers/:id/events',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Recent events on one Rust server'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped. Chat is read for each reader (USER_SAFETY.md §4.2): a signed-in reader does not get the lines of a player they blocked (by the player’s linked account, or by Steam id when there is none) or lines they hid for themselves, and each `player.chat` row carries `canBlock` — false for their own lines and for a player linked to a staff account. A line staff hid for everyone is left out for everybody but staff, who get it with `hidden: true`.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
|
||||
193
server/safety.js
Normal file
193
server/safety.js
Normal 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 }
|
||||
@@ -91,6 +91,12 @@ function fakeCtx(overrides = {}) {
|
||||
},
|
||||
},
|
||||
activity: { log: spy(Promise.resolve()) },
|
||||
// Report, block and hide (1.21.0). A reader with no blocks and no hides, and
|
||||
// nobody staff; a suite that needs either installs its own.
|
||||
safety: {
|
||||
forViewer: spy(Promise.resolve({ blockedUserIds: [], blockedIdentities: {}, hidden: {} })),
|
||||
staffAmong: spy(Promise.resolve([])),
|
||||
},
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
@@ -175,6 +181,9 @@ function fakeApi() {
|
||||
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
|
||||
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
|
||||
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
|
||||
// MODULE_API 1.21.0 (report, block and hide, USER_SAFETY.md §4.2).
|
||||
registerReportTargets(targets) { once('registerReportTargets'); record.reportTargets = targets },
|
||||
registerGameIdentities(identities) { once('registerGameIdentities'); record.gameIdentities = identities },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
|
||||
349
server/test/safety.test.js
Normal file
349
server/test/safety.test.js
Normal file
@@ -0,0 +1,349 @@
|
||||
// ── Report, block and hide, for chat lines (USER_SAFETY.md §4.2) ───────────
|
||||
//
|
||||
// What this module promises core and the reader:
|
||||
//
|
||||
// • a chat line is answered only to a reader who could have seen it — a line
|
||||
// on a server whose presence is hidden from them, or one staff hid when they
|
||||
// are not staff, is the same `null` as a line that is gone;
|
||||
// • a speaker is known by their linked account first, by Steam id only when
|
||||
// there is none (S12);
|
||||
// • the feed leaves out what this reader blocked or hid, IN the query, and
|
||||
// marks each line `canBlock` for a signed-in reader (S11, S13).
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx, spy } = require('./_fakes')
|
||||
|
||||
function withCore(overrides = {}) {
|
||||
require('../core')._reset()
|
||||
const ctx = fakeCtx(overrides)
|
||||
require('../core').init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Swap named exports on a required file for the length of `fn`. */
|
||||
async function stubbing(mod, stubs, fn) {
|
||||
const saved = {}
|
||||
for (const [name, value] of Object.entries(stubs)) {
|
||||
saved[name] = mod[name]
|
||||
mod[name] = value
|
||||
}
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
Object.assign(mod, saved)
|
||||
}
|
||||
}
|
||||
|
||||
const LINE = {
|
||||
id: 4410,
|
||||
serverId: 'main',
|
||||
wipeId: 'w1',
|
||||
t: Date.UTC(2026, 9, 30, 10, 0, 0),
|
||||
steamId: '76561198000000001',
|
||||
raw: JSON.stringify({ name: 'Rusty', message: 'gg ez', channel: 'Global' }),
|
||||
hiddenAt: null,
|
||||
}
|
||||
|
||||
const SEEN = { visible: true, level: 'signed_in', required: 'signed_in', userId: 7 }
|
||||
|
||||
function deps() {
|
||||
return {
|
||||
eventsDb: require('../model/events/events.db'),
|
||||
linksDb: require('../model/links/links.db'),
|
||||
serversDb: require('../model/servers/servers.db'),
|
||||
visibility: require('../model/visibility/visibility.model'),
|
||||
safety: require('../safety'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── registration ──────────────────────────────────────────────────────────
|
||||
|
||||
test('rust.chat and the steam identity are registered, in core’s shape', () => {
|
||||
require('../core')._reset()
|
||||
const { fakeApi } = require('./_fakes')
|
||||
const api = fakeApi()
|
||||
require('../index')(fakeCtx(), api)
|
||||
|
||||
const [target] = api.record.reportTargets
|
||||
assert.strictEqual(api.record.reportTargets.length, 1)
|
||||
assert.strictEqual(target.id, 'rust.chat')
|
||||
// Core's rule: namespaced `<moduleId>.`, at most 32 characters, a label of 40.
|
||||
assert.match(target.id, /^rust\.[a-z0-9._-]+$/)
|
||||
assert.ok(target.id.length <= 32 && target.label.length <= 40)
|
||||
for (const name of ['snapshot', 'hide', 'unhide']) assert.strictEqual(typeof target[name], 'function')
|
||||
|
||||
const [identity] = api.record.gameIdentities
|
||||
assert.strictEqual(api.record.gameIdentities.length, 1)
|
||||
assert.strictEqual(identity.kind, 'steam')
|
||||
assert.strictEqual(typeof identity.ownersOf, 'function')
|
||||
})
|
||||
|
||||
// ── snapshot ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('a line is described for a reader who could see it, with the linked account', async () => {
|
||||
withCore()
|
||||
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
|
||||
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () =>
|
||||
stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: LINE.steamId, userId: 12 }] }, () =>
|
||||
stubbing(serversDb, { getServer: async () => ({ name: 'Main' }) }, async () => {
|
||||
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } })
|
||||
assert.deepStrictEqual(snap, {
|
||||
author: { name: 'Rusty', userId: 12, identity: { kind: 'steam', id: LINE.steamId } },
|
||||
text: 'gg ez',
|
||||
at: '2026-10-30T10:00:00.000Z',
|
||||
where: { server: 'main', serverName: 'Main', channel: 'Global', wipe: 'w1' },
|
||||
})
|
||||
}))))
|
||||
})
|
||||
|
||||
test('an unlinked speaker has no account, only their Steam id', async () => {
|
||||
withCore()
|
||||
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
|
||||
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () =>
|
||||
stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () =>
|
||||
stubbing(serversDb, { getServer: async () => null }, async () => {
|
||||
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } })
|
||||
assert.strictEqual(snap.author.userId, null)
|
||||
assert.deepStrictEqual(snap.author.identity, { kind: 'steam', id: LINE.steamId })
|
||||
}))))
|
||||
})
|
||||
|
||||
test('a line the reader could not have seen is the same null as a missing one', async () => {
|
||||
withCore()
|
||||
const { eventsDb, visibility, safety } = deps()
|
||||
const viewer = { userId: 7, role: 'player' }
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => null }, async () => {
|
||||
assert.strictEqual(await safety.snapshot({ targetId: 1, viewer }), null)
|
||||
})
|
||||
|
||||
// Presence hidden from this reader on the line's server.
|
||||
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
|
||||
stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, visible: false }) }, async () => {
|
||||
assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null)
|
||||
}))
|
||||
|
||||
// Hidden by staff, and the reader is not staff (S10).
|
||||
const hidden = { ...LINE, hiddenAt: new Date() }
|
||||
await stubbing(eventsDb, { chatLineById: async () => hidden }, () =>
|
||||
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, async () => {
|
||||
assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null)
|
||||
}))
|
||||
})
|
||||
|
||||
test('staff still get a line staff hid, so the report can be read', async () => {
|
||||
withCore()
|
||||
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
|
||||
const hidden = { ...LINE, hiddenAt: new Date() }
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => hidden }, () =>
|
||||
stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, level: 'staff' }) }, () =>
|
||||
stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () =>
|
||||
stubbing(serversDb, { getServer: async () => null }, async () => {
|
||||
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 1, role: 'moderator' } })
|
||||
assert.strictEqual(snap.text, 'gg ez')
|
||||
}))))
|
||||
})
|
||||
|
||||
test('the presence check is made for the account core names, not the session role', async () => {
|
||||
withCore()
|
||||
const { eventsDb, visibility, safety } = deps()
|
||||
const asked = []
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
|
||||
stubbing(visibility, { canSeePresenceAs: async (userId, serverId) => { asked.push([userId, serverId]); return { ...SEEN, visible: false } } }, async () => {
|
||||
await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'admin' } })
|
||||
}))
|
||||
assert.deepStrictEqual(asked, [[7, 'main']])
|
||||
})
|
||||
|
||||
// ── hide for everyone ─────────────────────────────────────────────────────
|
||||
|
||||
test('hide sets the flag with who did it; a pruned line is left alone', async () => {
|
||||
withCore()
|
||||
const { eventsDb, safety } = deps()
|
||||
const set = spy(Promise.resolve())
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => {
|
||||
await safety.hide({ targetId: 4410, actor: { id: 3, username: 'moderator', role: 'moderator' } })
|
||||
await safety.unhide({ targetId: 4410, actor: { id: 3 } })
|
||||
})
|
||||
assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false]])
|
||||
|
||||
const none = spy(Promise.resolve())
|
||||
await stubbing(eventsDb, { chatLineById: async () => null, setChatHidden: none }, async () => {
|
||||
await safety.hide({ targetId: 9, actor: { id: 3 } })
|
||||
})
|
||||
assert.deepStrictEqual(none.calls, [])
|
||||
})
|
||||
|
||||
function fakeRes() {
|
||||
const res = { statusCode: 200, body: null }
|
||||
res.status = (code) => { res.statusCode = code; return res }
|
||||
res.json = (body) => { res.body = body; return res }
|
||||
res.end = () => res
|
||||
return res
|
||||
}
|
||||
|
||||
test('the line’s own menu hides and unhides through admin routes, logged', async () => {
|
||||
const ctx = withCore()
|
||||
const { eventsDb } = deps()
|
||||
const admin = require('../router/admin/rust.controller')
|
||||
const set = spy(Promise.resolve())
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => {
|
||||
const res = fakeRes()
|
||||
await admin.hideChat({ params: { id: '4410' }, user: { id: 3 } }, res)
|
||||
assert.strictEqual(res.statusCode, 204)
|
||||
await admin.unhideChat({ params: { id: '4410' }, user: { id: 3 } }, fakeRes())
|
||||
})
|
||||
assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false, 3]])
|
||||
assert.deepStrictEqual(ctx.activity.log.calls.map(([e]) => e.action), ['rust.chat.hide', 'rust.chat.unhide'])
|
||||
|
||||
await stubbing(eventsDb, { chatLineById: async () => null }, async () => {
|
||||
const res = fakeRes()
|
||||
await admin.hideChat({ params: { id: '9' }, user: { id: 3 } }, res)
|
||||
assert.strictEqual(res.statusCode, 404)
|
||||
})
|
||||
})
|
||||
|
||||
// ── the steam identity ────────────────────────────────────────────────────
|
||||
|
||||
test('ownersOf answers linked ids only, and asks nothing for an empty list', async () => {
|
||||
withCore()
|
||||
const { linksDb, safety } = deps()
|
||||
const asked = spy(async () => [{ steamId: '1', userId: 5 }])
|
||||
|
||||
await stubbing(linksDb, { userIdsForSteamIds: asked }, async () => {
|
||||
assert.deepStrictEqual(await safety.ownersOf(['1', '2', '1', null]), { 1: 5 })
|
||||
assert.deepStrictEqual(await safety.ownersOf([]), {})
|
||||
})
|
||||
assert.deepStrictEqual(asked.calls, [[['1', '2']]])
|
||||
})
|
||||
|
||||
// ── the feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('a signed-out reader is filtered by nothing but staff’s hides', async () => {
|
||||
const ctx = withCore()
|
||||
const { safety } = deps()
|
||||
|
||||
assert.deepStrictEqual(await safety.feedFilter({ userId: null, staff: false }), { showHidden: false, steamIds: [], ids: [] })
|
||||
assert.deepStrictEqual(ctx.safety.forViewer.calls, [])
|
||||
})
|
||||
|
||||
test('a reader’s blocks become Steam ids: an account’s linked ids, and unlinked Steam blocks', async () => {
|
||||
withCore({
|
||||
safety: {
|
||||
forViewer: async () => ({
|
||||
blockedUserIds: [12],
|
||||
blockedIdentities: { steam: ['900'] },
|
||||
hidden: { 'rust.chat': [4410, 4411] },
|
||||
}),
|
||||
staffAmong: async () => [],
|
||||
},
|
||||
})
|
||||
const { linksDb, safety } = deps()
|
||||
const asked = spy(async () => ['100', '101'])
|
||||
|
||||
await stubbing(linksDb, { steamIdsForUsers: asked }, async () => {
|
||||
assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: true }), {
|
||||
showHidden: true,
|
||||
steamIds: ['100', '101', '900'],
|
||||
ids: [4410, 4411],
|
||||
})
|
||||
})
|
||||
assert.deepStrictEqual(asked.calls, [[[12]]])
|
||||
})
|
||||
|
||||
test('a feed whose blocks cannot be read is shown unfiltered, not failed', async () => {
|
||||
withCore({ safety: { forViewer: async () => { throw new Error('db down') }, staffAmong: async () => [] } })
|
||||
const { safety } = deps()
|
||||
assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: false }), { showHidden: false, steamIds: [], ids: [] })
|
||||
})
|
||||
|
||||
test('canBlock: not your own lines, not a staff-linked player, not a line with no Steam id', async () => {
|
||||
withCore({ safety: { forViewer: async () => ({}), staffAmong: async (ids) => ids.filter((id) => id === 2) } })
|
||||
const { linksDb, safety } = deps()
|
||||
const rows = [
|
||||
{ id: 1, kind: 'player.chat', steamId: 'mine' },
|
||||
{ id: 2, kind: 'player.chat', steamId: 'staff' },
|
||||
{ id: 3, kind: 'player.chat', steamId: 'linked' },
|
||||
{ id: 4, kind: 'player.chat', steamId: 'unlinked' },
|
||||
{ id: 5, kind: 'player.chat', steamId: null },
|
||||
{ id: 6, kind: 'player.death', steamId: 'linked' },
|
||||
]
|
||||
|
||||
await stubbing(linksDb, {
|
||||
userIdsForSteamIds: async () => [
|
||||
{ steamId: 'mine', userId: 7 },
|
||||
{ steamId: 'staff', userId: 2 },
|
||||
{ steamId: 'linked', userId: 12 },
|
||||
],
|
||||
}, async () => {
|
||||
const out = await safety.annotate(rows, { userId: 7 })
|
||||
assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false, true, true, false, undefined])
|
||||
// Signed out: untouched.
|
||||
assert.strictEqual(await safety.annotate(rows, { userId: null }), rows)
|
||||
})
|
||||
})
|
||||
|
||||
test('when who is staff cannot be read, no line offers Block', async () => {
|
||||
withCore({ safety: { forViewer: async () => ({}), staffAmong: async () => { throw new Error('db down') } } })
|
||||
const { linksDb, safety } = deps()
|
||||
|
||||
await stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: 'a', userId: 12 }] }, async () => {
|
||||
const out = await safety.annotate([{ id: 1, kind: 'player.chat', steamId: 'a' }, { id: 2, kind: 'player.chat', steamId: 'b' }], { userId: 7 })
|
||||
assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false])
|
||||
})
|
||||
})
|
||||
|
||||
// ── the query ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the feed’s query leaves hidden, blocked and hidden-for-me chat out, and only chat', async () => {
|
||||
const ctx = withCore()
|
||||
const db = require('../model/events/events.db')
|
||||
|
||||
await db.recentEvents({ serverId: 'main', kinds: ['player.chat', 'player.death'], limit: 50 })
|
||||
let [sql, params] = ctx.db.query.calls.at(-1)
|
||||
assert.match(sql, /NOT \(kind = 'player\.chat' AND hidden_at IS NOT NULL\)/)
|
||||
assert.deepStrictEqual(params, ['main', 'player.chat', 'player.death', 50])
|
||||
|
||||
await db.recentEvents({
|
||||
serverId: 'main',
|
||||
kinds: ['player.chat'],
|
||||
limit: 50,
|
||||
chat: { showHidden: true, steamIds: ['100', '900'], ids: [4410] },
|
||||
});
|
||||
[sql, params] = ctx.db.query.calls.at(-1)
|
||||
assert.doesNotMatch(sql, /hidden_at IS NOT NULL/, 'staff get hidden lines')
|
||||
assert.match(sql, /NOT \(kind = 'player\.chat' AND steam_id IN \(\?, \?\)\)/)
|
||||
assert.match(sql, /NOT \(kind = 'player\.chat' AND id IN \(\?\)\)/)
|
||||
assert.deepStrictEqual(params, ['main', 'player.chat', '100', '900', 4410, 50])
|
||||
})
|
||||
|
||||
test('the model passes the reader’s chat filter through, narrow by default, and marks staff’s hidden lines', async () => {
|
||||
withCore()
|
||||
const db = require('../model/events/events.db')
|
||||
const model = require('../model/events/events.model')
|
||||
let asked = null
|
||||
|
||||
await stubbing(db, {
|
||||
recentEvents: async (args) => {
|
||||
asked = args
|
||||
return [{ id: 1, kind: 'player.chat', t: 1, steamId: 'a', raw: '{}', hiddenAt: new Date() }]
|
||||
},
|
||||
}, async () => {
|
||||
await model.recent({ serverId: 'main', presence: true })
|
||||
assert.deepStrictEqual(asked.chat, { showHidden: false, steamIds: [], ids: [] })
|
||||
|
||||
const rows = await model.recent({ serverId: 'main', presence: true, chat: { showHidden: true, steamIds: ['x'], ids: [2] } })
|
||||
assert.deepStrictEqual(asked.chat, { showHidden: true, steamIds: ['x'], ids: [2] })
|
||||
assert.strictEqual(rows[0].hidden, true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user