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

@@ -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,

View File

@@ -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(

View File

@@ -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,
})

View File

@@ -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' } }