Files
Module-Rust/server/model/visibility/visibility.model.js
wtclaude dd9b8b2711
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
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
2026-10-30 09:04:08 -05:00

350 lines
14 KiB
JavaScript

// ── Who may see who is online ─────────────────────────────────────────────
//
// The org lead's rule, settled 2026-09-22: **nothing tells who is online by
// default.** It is always the lowest blast radius — staff — unless an operator
// deliberately widens it, and a COUNT of players is fine where a list of names
// is not.
//
// "Who is online" is wider than the Online tab. Every frame that says a named
// player was on the server at a given moment says it: a connect, a respawn, a
// death, a chat line, a gather tally (`catalogue.PRESENCE_KINDS`), and a
// leaderboard row's `lastSeen`, which a tally refreshes every minute while
// somebody plays. All of them sit behind this one setting.
//
// ── The audiences ─────────────────────────────────────────────────────────
//
// staff an admin or a moderator — the two roles every Team surface in
// core also means by "staff"
// signed_in any active website account
// public anybody, signed in or not
//
// Ordered, each rung implying the ones below it. The names line up with phase
// 14's map-layer switches (public / players / admin) so that one layer can take
// this over rather than sit beside it.
//
// ── Two fallbacks, deliberately asymmetric ────────────────────────────────
//
// An unrecognised VIEWER reads as the bottom rung and an unrecognised
// REQUIREMENT reads as the top one, so a value nobody expected always loses.
// One shared fallback cannot do that: whichever way it points, it fails open on
// one side. module-uo's shard visibility learned this the hard way; the rule is
// copied here rather than rediscovered.
const core = require('../../core')
const db = require('./visibility.db')
const log = core.logger('visibility')
const AUDIENCES = Object.freeze(['public', 'signed_in', 'staff'])
const RANK = new Map(AUDIENCES.map((a, i) => [a, i]))
/** The narrowest rung, and the default wherever nothing has been chosen. */
const DEFAULT_PRESENCE = 'staff'
/** The `rust_settings` key the fleet default lives under. */
const PRESENCE_KEY = 'presence.audience'
const isAudience = (value) => RANK.has(value)
// ── Who may see a clan's roster (phase 9, D48) ────────────────────────────
//
// The same rule applied to a roster: a roster says who is in a clan and, inside
// its audience, which of them is on. So it defaults to the clan's OWN members
// plus staff, and an operator widens it deliberately.
//
// members staff, and a website account linked to one of the clan's members
// signed_in any active website account
// public anybody
//
// One fleet-wide setting (D48), deliberately without a per-server override: the
// presence override exists because a PvE server may publish a roll call a PvP one
// must not, and a roster is the same answer on every server of the fleet.
//
// **Widening it widens online status too.** Core's `projectRoster` can withhold a
// roster's rows but not its fields, so there is no rung that shows who is in a
// clan and hides which of them is on. The admin page says so beside the switch.
const CLAN_AUDIENCES = Object.freeze(['public', 'signed_in', 'members'])
/** The narrowest rung, and the default until an operator chooses. */
const DEFAULT_CLAN_ROSTER = 'members'
/** The `rust_settings` key the roster audience lives under. */
const CLAN_ROSTER_KEY = 'clans.roster.audience'
const isClanAudience = (value) => CLAN_AUDIENCES.includes(value)
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
/** Does a viewer at `viewer` satisfy a requirement of `required`? */
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
/**
* The viewer's rung, re-read from the database.
*
* `getUserFromRequest` decodes a token and nothing more — the role in it is the
* role the account had when it signed in. For a gate on who may see who is
* online, that is not good enough: a moderator demoted this morning would keep
* the roll call until their token expired, and a banned account would keep
* reading it too. So the token only says WHO; the row says what they are now.
*
* Any failure resolves to `public` — the bottom rung — because an unanswerable
* question about somebody's standing must grant nothing.
*/
async function viewerLevel(req) {
return (await viewer(req)).level
}
/**
* The viewer's rung AND who they are, from the same re-read row — `{ level,
* userId }`, with `userId` null for anybody who resolves to `public`. The map's
* own-and-mates view (D115) needs the id, and it must come from the row that
* decided the rung: a banned account is nobody, whatever its token names.
*/
async function viewer(req) {
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
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 }
}
}
/**
* 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 : userId }
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return nobody
}
}
/** A stored value as an audience, narrowing anything this build does not recognise. */
function normalise(value) {
return isAudience(value) ? value : DEFAULT_PRESENCE
}
/** The fleet default. */
async function fleetPresence() {
const stored = await db.getSetting(PRESENCE_KEY)
return stored == null ? DEFAULT_PRESENCE : normalise(stored)
}
/**
* The audience that applies to one server: its override if it has one, the
* fleet default otherwise.
*
* A server that does not exist gets the fleet default, which is the right answer
* for the routes that call this: they answer an empty list for an unknown id,
* and an empty list is empty at every rung.
*/
async function presenceFor(serverId) {
const override = await db.getServerPresence(serverId)
if (override != null) return normalise(override)
return fleetPresence()
}
/**
* Everything a public route needs in one call: may this viewer see who is on
* this server?
*
* Throws nothing. A setting that cannot be read resolves to "no" — the routes
* that ask would otherwise have to choose between a 500 and publishing names.
*/
async function canSeePresence(req, serverId) {
try {
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, 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 }
}
}
/**
* The clan roster audience. An unrecognised stored word narrows to `members`,
* and a read that fails throws — every caller answers "no" on a throw, which is
* the direction a roster must fail in.
*/
async function clanRosterAudience() {
const stored = await db.getSetting(CLAN_ROSTER_KEY)
return isClanAudience(stored) ? stored : DEFAULT_CLAN_ROSTER
}
/** The admin screen's read: the fleet default and every server beside it. */
async function describe() {
const [fleet, servers, clanRoster] = await Promise.all([
fleetPresence(),
db.listServerPresence(),
clanRosterAudience(),
])
return {
audiences: [...AUDIENCES],
clans: { audiences: [...CLAN_AUDIENCES], roster: clanRoster },
presence: {
fleet,
servers: servers.map((s) => {
const override = s.presence == null ? null : normalise(s.presence)
return {
id: s.id,
name: s.name,
enabled: Boolean(s.enabled),
override,
effective: override || fleet,
}
}),
},
// D104/D106: whether a published news post is said in each server's chat.
// On this page because it is the one that lists every server with a setting
// of its own, and it answers the same kind of question — what a server shows.
news: {
servers: servers.map((s) => ({
id: s.id,
name: s.name,
enabled: Boolean(s.enabled),
on: Boolean(Number(s.announceNews)),
// D142: where the post goes when the switch is on. A word this build
// does not know reads as chat, which every server can do.
delivery: s.newsDelivery === 'popup' ? 'popup' : 'chat',
})),
},
}
}
/**
* The admin screen's write.
*
* `fleet` is optional; `servers` maps an id to an audience, or to `null` to
* clear its override. Validated whole before anything is written, so a request
* naming one unknown server changes nothing rather than half of what it asked.
*
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
async function update({ fleet, servers, clanRoster, news, newsDelivery } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
if (clanRoster !== undefined && !isClanAudience(clanRoster)) {
return {
ok: false,
status: 400,
message: `"${clanRoster}" is not a clan roster audience. Choose one of: ${CLAN_AUDIENCES.join(', ')}.`,
}
}
const changes = Object.entries(servers || {})
for (const [id, value] of changes) {
if (value !== null && !isAudience(value)) {
return { ok: false, status: 400, message: `"${value}" is not an audience for server ${id}.` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const newsChanges = Object.entries(news || {})
for (const [id, value] of newsChanges) {
if (typeof value !== 'boolean') {
return { ok: false, status: 400, message: `News in game chat is on or off for server ${id}, not "${value}".` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const deliveryChanges = Object.entries(newsDelivery || {})
for (const [id, value] of deliveryChanges) {
if (value !== 'chat' && value !== 'popup') {
return { ok: false, status: 400, message: `News goes to chat or to a popup on server ${id}, not "${value}".` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
if (clanRoster !== undefined) await db.setSetting(CLAN_ROSTER_KEY, clanRoster, userId)
for (const [id, value] of changes) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
}
for (const [id, on] of newsChanges) {
// eslint-disable-next-line no-await-in-loop
await db.setServerNews(id, on)
}
for (const [id, delivery] of deliveryChanges) {
// eslint-disable-next-line no-await-in-loop
await db.setServerNewsDelivery(id, delivery)
}
// What was written, for the controller's audit row. Recorded there rather than
// here because the activity log takes the REQUEST (who, from where), and a
// model that took a request would be a model that could only be called by one.
return {
ok: true,
changed: {
...(fleet !== undefined ? { fleet } : {}),
...(clanRoster !== undefined ? { clanRoster } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
...(deliveryChanges.length ? { newsDelivery: Object.fromEntries(deliveryChanges) } : {}),
},
}
}
module.exports = {
AUDIENCES,
DEFAULT_PRESENCE,
PRESENCE_KEY,
isAudience,
CLAN_AUDIENCES,
DEFAULT_CLAN_ROSTER,
CLAN_ROSTER_KEY,
isClanAudience,
clanRosterAudience,
meets,
normalise,
viewerLevel,
viewer,
viewerById,
fleetPresence,
presenceFor,
canSeePresence,
canSeePresenceAs,
describe,
update,
}