Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70bafdff63 | |||
| 0c29dbf6eb | |||
| dd9b8b2711 | |||
| 1fb685fa0f | |||
| bd13f197c5 | |||
| e30c7d591d | |||
| b5573d9ae3 | |||
| 24307a95d4 | |||
| ec90d2d5ae | |||
| fb7511d700 | |||
| 161457dbf3 | |||
| 5c9cefdbc2 | |||
| 1f4ad25edb | |||
| 228e178475 | |||
| 0cdb046d30 | |||
| 791f13235e | |||
| c61f9f29ec | |||
| 7a0d0842ba |
@@ -384,6 +384,22 @@ jobs:
|
||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
||||
|
||||
# The wire protocol this release speaks, for the public demo's wipe
|
||||
# (website/DEMO_MODE.md §7.5, DM12). The wipe picks the newest release
|
||||
# whose `protocol` EQUALS the Rust bundle's. The Rust link has no 409,
|
||||
# so a plugin on another protocol would mis-parse rather than be
|
||||
# refused. Read from the one declaration (`server/sidecarClient.js`);
|
||||
# a release that cannot find it fails here rather than ship a manifest
|
||||
# the wipe would skip. Core's installer reads only the fields it names
|
||||
# (modules/install.js), so the extra key changes nothing there.
|
||||
PROTOCOL="$(node -e '
|
||||
const src = require("fs").readFileSync("server/sidecarClient.js", "utf8");
|
||||
const m = /^const PROTOCOL_VERSION = (\d+)$/m.exec(src);
|
||||
if (!m) { console.error("no PROTOCOL_VERSION in server/sidecarClient.js"); process.exit(1); }
|
||||
console.log(m[1]);
|
||||
')"
|
||||
echo "protocol ${PROTOCOL}"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
@@ -396,9 +412,10 @@ jobs:
|
||||
--arg artifact "module-rust-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--argjson protocol "$PROTOCOL" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size, protocol:$protocol}' \
|
||||
> "dist/module-rust-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"core.js",
|
||||
"dangerousTreasures.js",
|
||||
"db",
|
||||
"demo.js",
|
||||
"demoSeed.js",
|
||||
"dynamicPvp.js",
|
||||
"eventCommands.js",
|
||||
"engagement",
|
||||
@@ -59,6 +61,7 @@
|
||||
"permSync.js",
|
||||
"raidableBases.js",
|
||||
"router",
|
||||
"safety.js",
|
||||
"sidecarClient.js",
|
||||
"titleSync.js",
|
||||
"worldEvents.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change.",
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change). **2026-10-30, account deletion (ACCOUNT_DELETION.md §4.4):** this module registers onUserDeleting (MODULE_API 1.20.0), so coreApi is ^1.20.0, pinned to `main` at the sha website#232 merged as. **2026-10-30, report, block and hide (USER_SAFETY.md §4.2):** this module registers a rust.chat report target and the steam game identity, and reads ctx.safety (MODULE_API 1.21.0), so coreApi is ^1.21.0, pinned to `main` at the sha website#233 merged as.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
|
||||
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
|
||||
"ref": "f13b8fe1fec9dc330a985430b8bd4f67c9ec26c5",
|
||||
"refName": "website main @ website#233 merge, MODULE_API 1.21.0"
|
||||
}
|
||||
|
||||
@@ -350,6 +350,21 @@ export const adminUserBackpacks = {
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
|
||||
}
|
||||
|
||||
// ── report, block and hide (USER_SAFETY.md §4.2) ───────────────────────────
|
||||
// Core's routes, not this module's: the Reports queue, a member's blocks and
|
||||
// their "Hide for me" list are core's and hold for every module and the forum.
|
||||
// Each names an ITEM — `{ targetType: 'rust.chat', targetId }` — never a person;
|
||||
// core asks `server/safety.js` who said it. The last two are this module's own:
|
||||
// staff's hide for everyone, set and cleared where the line lives.
|
||||
export const safety = {
|
||||
report: ({ targetType, targetId, reason, detail }) =>
|
||||
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
|
||||
block: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
|
||||
hide: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
|
||||
hideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/hide`, { method: 'POST' }),
|
||||
unhideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/unhide`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
// ── core's public event calendar ───────────────────────────────────────────
|
||||
// The one CORE route this module reads, and core published `runId` on it for
|
||||
// exactly this: a map marker holds a run id and nothing else, and the calendar is
|
||||
@@ -382,6 +397,7 @@ export default {
|
||||
adminUserPermissions,
|
||||
adminUserEconomics,
|
||||
adminUserBackpacks,
|
||||
safety,
|
||||
coreEvents,
|
||||
BASE,
|
||||
}
|
||||
|
||||
198
client/src/components/ChatMenu.jsx
Normal file
198
client/src/components/ChatMenu.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// ── A chat line's menu: report, hide, block (USER_SAFETY.md §4.2) ──────────
|
||||
//
|
||||
// One small button at the end of each chat line in the feed, opening a row of
|
||||
// actions under it. What it offers is `lib/safety.js`'s `chatActions`; what it
|
||||
// does is core's (report, block, Hide for me) or this module's admin route
|
||||
// (staff's hide for everyone). After anything that changes what the reader
|
||||
// sees, the feed is asked again: the server is what filters it (S11), so the
|
||||
// honest way to show a block is to re-read the feed, never to drop the line here.
|
||||
//
|
||||
// The wording follows core's forum menu, so a member reads one set of sentences
|
||||
// wherever they report or block from.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../core.js'
|
||||
import api from '../api.js'
|
||||
import { CHAT_TARGET, REPORT_REASONS, chatActions } from '../lib/safety.js'
|
||||
|
||||
export default function ChatMenu({ line, onChanged }) {
|
||||
const { user } = useAuth()
|
||||
const actions = chatActions(line, user)
|
||||
const [mode, setMode] = useState('closed') // closed | open | report | block | done
|
||||
const [reason, setReason] = useState('abuse')
|
||||
const [detail, setDetail] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
if (!actions) return null
|
||||
|
||||
// Every move between the menu's states drops the last error: a refusal from
|
||||
// one action ("you have already reported this") is not about the next.
|
||||
const go = (next) => { setError(null); setMode(next) }
|
||||
|
||||
const target = { targetType: CHAT_TARGET, targetId: line.id }
|
||||
const name = (line.frame && line.frame.name) || 'this player'
|
||||
|
||||
const run = async (call, { refetch = true, done = null } = {}) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await call()
|
||||
if (done) {
|
||||
setMessage(done)
|
||||
setMode('done')
|
||||
} else {
|
||||
setMode('closed')
|
||||
}
|
||||
if (refetch) await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not do that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setError(null); setMode(mode === 'closed' ? 'open' : 'closed') }}
|
||||
aria-label={`Actions for ${name}’s line`}
|
||||
aria-expanded={mode !== 'closed'}
|
||||
title="Report, hide or block"
|
||||
style={toggleStyle}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
)
|
||||
|
||||
if (mode === 'closed') return toggle
|
||||
|
||||
return (
|
||||
<>
|
||||
{toggle}
|
||||
<div className="sans" style={panelStyle}>
|
||||
{mode === 'done' && <p style={noteStyle}>{message}</p>}
|
||||
|
||||
{mode === 'open' && actions.signIn && (
|
||||
<p style={noteStyle}>
|
||||
<Link to="/account/login" state={{ from: { pathname: window.location.pathname } }}>Sign in</Link>
|
||||
{' '}to report or block.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mode === 'open' && !actions.signIn && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{actions.report && (
|
||||
<button type="button" className="pill" onClick={() => go('report')}>Report</button>
|
||||
)}
|
||||
{actions.hide && (
|
||||
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hide(target))}>
|
||||
Hide for me
|
||||
</button>
|
||||
)}
|
||||
{actions.block && (
|
||||
<button type="button" className="pill" onClick={() => go('block')}>Block player</button>
|
||||
)}
|
||||
{actions.staff === 'hide' && (
|
||||
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hideForEveryone(line.id))}>
|
||||
Hide for everyone
|
||||
</button>
|
||||
)}
|
||||
{actions.staff === 'unhide' && (
|
||||
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.unhideForEveryone(line.id))}>
|
||||
Show to everyone again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'report' && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
run(() => api.safety.report({ ...target, reason, detail: detail || undefined }), {
|
||||
refetch: false,
|
||||
done: 'Reported to site staff.',
|
||||
})
|
||||
}}
|
||||
style={{ display: 'grid', gap: 8 }}
|
||||
>
|
||||
{/* A report changes nothing about the line, and says so: a member who
|
||||
expects it to vanish and watches it stay will report it again. */}
|
||||
<p style={noteStyle}>
|
||||
This goes to <strong>site staff</strong>. Reporting does not hide or change anything — it asks a
|
||||
staffer to look.
|
||||
</p>
|
||||
<label style={{ fontSize: '0.85rem' }}>
|
||||
Reason
|
||||
{' '}
|
||||
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||
{REPORT_REASONS.map(([value, text]) => (
|
||||
<option key={value} value={value}>{text}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={detail}
|
||||
onChange={(e) => setDetail(e.target.value)}
|
||||
placeholder="Anything a staffer should know (optional)"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
|
||||
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'block' && (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{/* S9: the blocked player is not told, and a member deciding whether
|
||||
to block is usually deciding whether it will start something. */}
|
||||
<p style={noteStyle}>
|
||||
Block <strong>{name}</strong>? You won’t see anything they write, here or in the app. They
|
||||
aren’t told. You can undo it from <em>Account → Blocked</em>.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(() => api.safety.block(target))}>
|
||||
Block
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p style={{ ...noteStyle, color: 'var(--danger, crimson)' }}>{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const toggleStyle = {
|
||||
flex: 'none',
|
||||
marginLeft: 'auto',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
color: 'var(--dim)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1,
|
||||
padding: '0 4px',
|
||||
}
|
||||
|
||||
const panelStyle = {
|
||||
flexBasis: '100%',
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
marginTop: 6,
|
||||
padding: 10,
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.85rem',
|
||||
}
|
||||
|
||||
const noteStyle = { margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import ChatMenu from './ChatMenu.jsx'
|
||||
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||
import { ago, clock } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
@@ -32,7 +33,7 @@ const TONE = {
|
||||
export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
const kinds = kindsFor(filter)
|
||||
|
||||
const { data, error, loading, at } = usePolled(
|
||||
const { data, error, loading, at, refresh } = usePolled(
|
||||
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
|
||||
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
|
||||
// because what is on screen is an answer to a different one; a poll tick
|
||||
@@ -111,10 +112,14 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
key={event.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
// Staff's hidden line (S10): only staff ever get one, dimmed
|
||||
// and marked so the hide can be checked and undone here.
|
||||
opacity: event.hidden ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<time
|
||||
@@ -137,7 +142,17 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
{line.detail}
|
||||
</span>
|
||||
)}
|
||||
{event.hidden && (
|
||||
<span className="sans" style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
|
||||
{' · '}
|
||||
Hidden by staff
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* Report, Hide for me, Block player, and staff's Hide for
|
||||
everyone (USER_SAFETY.md §4.2). The server filtered the feed
|
||||
for this reader, so after any of them the feed is re-read. */}
|
||||
<ChatMenu line={event} onChanged={refresh} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
56
client/src/lib/safety.js
Normal file
56
client/src/lib/safety.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// ── Report, block and hide on a chat line: what the menu offers ────────────
|
||||
//
|
||||
// USER_SAFETY.md §4.2. Nothing here decides what a reader may SEE — the server
|
||||
// already left out what they blocked or hid, and staff's hidden lines for
|
||||
// everybody else (S11). This file only turns what the server said about a line
|
||||
// into which buttons its menu shows, and it is pure so a DOM-less runner can ask
|
||||
// all of it.
|
||||
|
||||
/** The report target a chat line is filed as. Registered by `server/safety.js`. */
|
||||
export const CHAT_TARGET = 'rust.chat'
|
||||
|
||||
/**
|
||||
* The reasons a report can give. Core's `content_reports.reason` enum, in the
|
||||
* order and words core's own forum report uses, so a member reads one list
|
||||
* wherever they report from.
|
||||
*/
|
||||
export const REPORT_REASONS = Object.freeze([
|
||||
['abuse', 'Abusive or harassing'],
|
||||
['spam', 'Spam'],
|
||||
['sexual', 'Sexual content'],
|
||||
['illegal', 'Illegal content'],
|
||||
['impersonation', 'Impersonation'],
|
||||
['other', 'Something else'],
|
||||
])
|
||||
|
||||
/**
|
||||
* The roles that hide a line for everyone: the two core's Reports queue is open
|
||||
* to, and the two `POST /admin/rust/chat/:id/hide` admits. An editor writes
|
||||
* content and moderates none.
|
||||
*/
|
||||
export const HIDE_ROLES = Object.freeze(['admin', 'moderator'])
|
||||
|
||||
/**
|
||||
* What a chat line's menu offers this reader.
|
||||
*
|
||||
* signIn a signed-out reader gets one line telling them to sign in (S6)
|
||||
* report any signed-in reader, on any line
|
||||
* hide "Hide for me", likewise — a staff member's line included (S8)
|
||||
* block only where the server said `canBlock` (S8, S13)
|
||||
* staff 'hide' or 'unhide' for a reader who can hide for everyone (S10)
|
||||
*
|
||||
* @param {{ kind: string, canBlock?: boolean, hidden?: boolean }} line
|
||||
* @param {{ role?: string } | null} user
|
||||
*/
|
||||
export function chatActions(line, user) {
|
||||
if (!line || line.kind !== 'player.chat') return null
|
||||
if (!user) return { signIn: true, report: false, hide: false, block: false, staff: null }
|
||||
const canHideForAll = HIDE_ROLES.includes(user.role)
|
||||
return {
|
||||
signIn: false,
|
||||
report: true,
|
||||
hide: true,
|
||||
block: line.canBlock === true,
|
||||
staff: canHideForAll ? (line.hidden ? 'unhide' : 'hide') : null,
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function formFrom(server) {
|
||||
isNew: false,
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl || '',
|
||||
sidecarToken: '',
|
||||
enabled: server.enabled,
|
||||
sortOrder: server.sortOrder,
|
||||
@@ -264,7 +264,7 @@ export default function ServerSettings() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
||||
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||||
{s.sidecarBaseUrl || 'sidecar address hidden'} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||||
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
|
||||
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
||||
</div>
|
||||
|
||||
45
client/test/safety.test.js
Normal file
45
client/test/safety.test.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// ── A chat line's menu (USER_SAFETY.md §4.2) ───────────────────────────────
|
||||
//
|
||||
// The server decides what a reader sees and whether a line's speaker can be
|
||||
// blocked; this asks that the menu shows exactly what it said, and nothing a
|
||||
// signed-out reader could use.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { CHAT_TARGET, HIDE_ROLES, REPORT_REASONS, chatActions } from '../src/lib/safety.js'
|
||||
|
||||
const chat = (extra = {}) => ({ id: 1, kind: 'player.chat', steamId: '7656', frame: { name: 'Rusty', message: 'gg' }, ...extra })
|
||||
|
||||
test('the target is the one the server registers', () => {
|
||||
assert.equal(CHAT_TARGET, 'rust.chat')
|
||||
})
|
||||
|
||||
test('the six reasons are core’s report enum', () => {
|
||||
assert.deepEqual(REPORT_REASONS.map(([value]) => value).sort(), ['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'])
|
||||
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
|
||||
})
|
||||
|
||||
test('a signed-out reader is told to sign in, and offered nothing else (S6)', () => {
|
||||
assert.deepEqual(chatActions(chat({ canBlock: true }), null), { signIn: true, report: false, hide: false, block: false, staff: null })
|
||||
})
|
||||
|
||||
test('a member can report and hide any line, and block only where the server said so', () => {
|
||||
const member = { role: 'player' }
|
||||
assert.deepEqual(chatActions(chat({ canBlock: true }), member), { signIn: false, report: true, hide: true, block: true, staff: null })
|
||||
assert.equal(chatActions(chat({ canBlock: false }), member).block, false)
|
||||
// A line with no word from the server is not blockable.
|
||||
assert.equal(chatActions(chat(), member).block, false)
|
||||
})
|
||||
|
||||
test('admins and moderators can hide for everyone, and undo it; editors cannot', () => {
|
||||
assert.deepEqual([...HIDE_ROLES].sort(), ['admin', 'moderator'])
|
||||
assert.equal(chatActions(chat(), { role: 'moderator' }).staff, 'hide')
|
||||
assert.equal(chatActions(chat({ hidden: true }), { role: 'admin' }).staff, 'unhide')
|
||||
assert.equal(chatActions(chat(), { role: 'editor' }).staff, null)
|
||||
})
|
||||
|
||||
test('only chat lines have a menu', () => {
|
||||
assert.equal(chatActions({ id: 2, kind: 'player.death' }, { role: 'player' }), null)
|
||||
assert.equal(chatActions(null, { role: 'player' }), null)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
|
||||
"coreApi": "^1.17.0",
|
||||
"coreApi": "^1.20.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "rust.base.destroyed",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "rust",
|
||||
"name": "Rust",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.17.0",
|
||||
"coreApi": "^1.21.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
@@ -13,5 +13,10 @@
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
|
||||
"demoLocks": [
|
||||
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
|
||||
{ "method": "DELETE", "path": "/admin/rust/servers/:id" },
|
||||
{ "method": "DELETE", "path": "/player/rust/links/:steamId" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -251,6 +251,16 @@
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/chat/:id/hide",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/chat/:id/unhide",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
|
||||
@@ -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.
|
||||
@@ -186,4 +198,9 @@ module.exports = {
|
||||
// origin to be relative to. A getter over core's getter, so an operator who
|
||||
// changes `APP_BASE_URL` is read at the call, never captured at load.
|
||||
get baseUrl() { return need().site.baseUrl },
|
||||
|
||||
// Whether this is the public demo (MODULE_API 1.18.0, DEMO_MODE.md §3.4, §3.5).
|
||||
// Read through `demo.js`, which holds every lock it switches on. Only a real
|
||||
// `true` counts, so a core that predates the member reads as "not the demo".
|
||||
get demoMode() { return need().demoMode === true },
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
--
|
||||
|
||||
139
server/demo.js
Normal file
139
server/demo.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
|
||||
//
|
||||
// Core locks this module's two rig-link routes and the player's Steam unlink by
|
||||
// `demoLocks` in module.json. What a route list cannot express lives here, behind
|
||||
// `ctx.demoMode` (MODULE_API 1.18.0), and every handler that enforces one of these
|
||||
// asks this file:
|
||||
//
|
||||
// the staff Steam unlink on Admin → Users (step-5 walk, W4): it serves under
|
||||
// core's users mount, which a module's demoLocks may not name, so its handler
|
||||
// refuses instead. The demo player's seeded link stays put either way;
|
||||
// the console-command deny list (§3.4, DM11): the commands that would break
|
||||
// the rig for every visitor until the next wipe. Asked at save, through
|
||||
// core's `demoRefuses` hook (1.19.0, DM18), and again at run;
|
||||
// the bridge's own config (§3.5, DM14): neither readable nor writable, because
|
||||
// it holds the bridge's link to the sidecar and its credentials;
|
||||
// secrets in every other config: redacted on the way out, read-only on the
|
||||
// way in, and no raw-JSON saves.
|
||||
//
|
||||
// Off the demo nothing here refuses anything. The server is the lock (DM10):
|
||||
// the UI may explain a refusal, but no screen is relied on to make one.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const CODE = 'DEMO_LOCKED'
|
||||
const MESSAGE = 'This is turned off on the demo site.'
|
||||
|
||||
/** Whether this is the public demo. */
|
||||
function isDemo() {
|
||||
return core.demoMode
|
||||
}
|
||||
|
||||
/** The demo's refusal, in core's shape, so core's one toast shows it. */
|
||||
function refuse(res, message = MESSAGE, extra = {}) {
|
||||
return res.status(403).json({ message, code: CODE, ...extra })
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4, DM11) ─────────────────────────────
|
||||
|
||||
/** Denied by first word, exactly. */
|
||||
const DENIED = new Set(['quit', 'restart', 'oxide.unload', 'oxide.reload', 'fps.limit', 'server.tickrate'])
|
||||
|
||||
/** Denied by prefix: every RCON command. */
|
||||
const DENIED_PREFIXES = ['rcon.']
|
||||
|
||||
/**
|
||||
* The command a line runs, as the deny list compares it: the first word,
|
||||
* lower-cased, with a leading `global.` stripped (Rust accepts `global.quit`)
|
||||
* and the `o.` short form spelled `oxide.` (`o.unload`). Only the first word,
|
||||
* so arguments cannot hide a denied command; `;` does not split a Rust console
|
||||
* line (the §16 spike), so there is no second command to look for.
|
||||
*/
|
||||
function commandWord(line) {
|
||||
let word = String(line === undefined || line === null ? '' : line).trim().split(/\s+/)[0].toLowerCase()
|
||||
if (word.startsWith('global.')) word = word.slice('global.'.length)
|
||||
if (word.startsWith('o.')) word = `oxide.${word.slice('o.'.length)}`
|
||||
return word
|
||||
}
|
||||
|
||||
/** Whether one console line is denied on the demo, by its first word. */
|
||||
function isDeniedCommand(line) {
|
||||
const word = commandWord(line)
|
||||
if (!word) return false
|
||||
return DENIED.has(word) || DENIED_PREFIXES.some((prefix) => word.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal sentence for a list of console lines, or null when none is
|
||||
* denied — or when this is not the demo. Names the line as the author typed it.
|
||||
*/
|
||||
function deniedCommand(lines) {
|
||||
if (!isDemo()) return null
|
||||
const items = Array.isArray(lines) ? lines : lines === undefined || lines === null ? [] : [lines]
|
||||
const hit = items.find((line) => isDeniedCommand(line))
|
||||
return hit === undefined ? null : `"${String(hit).trim()}" is turned off on the demo site.`
|
||||
}
|
||||
|
||||
// ── The bridge's own config (§3.5, DM14) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bridge plugin's name. The catalogue's `self` is the plugin's own word for
|
||||
* it and is used when it is there; this is the floor, so a catalogue that could
|
||||
* not be read never unlocks the file (the lock fails closed).
|
||||
*/
|
||||
const BRIDGE = 'RunicGateway'
|
||||
|
||||
/**
|
||||
* A config path in the form the host will resolve it to, for the bridge test
|
||||
* only: `.` segments dropped, each segment trimmed of the spaces and trailing
|
||||
* dots Windows ignores, and lower-cased. `./RunicGateway.json` and
|
||||
* `RunicGateway /x.json` are the bridge's files on some host, so they are the
|
||||
* bridge's files here.
|
||||
*/
|
||||
function canonicalSegments(path) {
|
||||
return String(path || '')
|
||||
.split(/[\\/]+/)
|
||||
// A `.` segment trims to nothing and is dropped with the empty ones.
|
||||
.map((segment) => segment.trim().replace(/[. ]+$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a config path belongs to the bridge plugin: its first segment, or its
|
||||
* file name at the root, is the bridge's name. `self` is the catalogue's.
|
||||
*/
|
||||
function isBridgePath(path, self) {
|
||||
const names = new Set([BRIDGE.toLowerCase()])
|
||||
if (self) names.add(String(self).toLowerCase())
|
||||
const parts = canonicalSegments(path)
|
||||
if (!parts.length) return false
|
||||
const first = parts.length > 1 ? parts[0] : parts[0].replace(/\.json$/, '')
|
||||
return names.has(first)
|
||||
}
|
||||
|
||||
/** Whether a plugin name (a catalogue group, an audit row's plugin) is the bridge. */
|
||||
function isBridgePlugin(plugin, self) {
|
||||
const name = String(plugin || '').toLowerCase()
|
||||
return name === BRIDGE.toLowerCase() || (self ? name === String(self).toLowerCase() : false)
|
||||
}
|
||||
|
||||
// ── Secrets in every other config (§3.5) ───────────────────────────────────
|
||||
|
||||
/** What a redacted value reads as, in the text and in the form. */
|
||||
const REDACTED = '(hidden on the demo site)'
|
||||
|
||||
module.exports = {
|
||||
CODE,
|
||||
MESSAGE,
|
||||
BRIDGE,
|
||||
REDACTED,
|
||||
isDemo,
|
||||
refuse,
|
||||
commandWord,
|
||||
isDeniedCommand,
|
||||
deniedCommand,
|
||||
canonicalSegments,
|
||||
isBridgePath,
|
||||
isBridgePlugin,
|
||||
}
|
||||
190
server/demoSeed.js
Normal file
190
server/demoSeed.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ────
|
||||
//
|
||||
// `npm run seed:demo` hands each module its `modules.<id>` section of the
|
||||
// fixture, with every `"$env:NAME"` already replaced, and the demo accounts' ids
|
||||
// (DEMO_MODE.md §7.3). This module writes two kinds of row from it, through its
|
||||
// own models; core never writes them:
|
||||
//
|
||||
// servers the link to the rig: the sidecar URL and token come from the host's
|
||||
// .env, so the token is never in the repo
|
||||
// links demo-player's stand-in Steam id (DM6), so the player screens have
|
||||
// somebody to show
|
||||
// npcs the NPC faction table and profiles of the demo default set (DM26)
|
||||
// chat a BetterChat style per group (DM26)
|
||||
//
|
||||
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
|
||||
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
|
||||
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }],
|
||||
// "npcs": { "factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
|
||||
// "profiles": [{ "name": "bandit", "servers": ["main"], "body": { … } }] },
|
||||
// "chat": [{ "serverId": "main", "group": "vip", "style": { "Title": "[VIP]", … } }] }
|
||||
//
|
||||
// **Why NPCs and chat come from here, not the rig's baseline (DM26).** The rig is
|
||||
// restored before the site is wiped, and a fresh site imports what it finds
|
||||
// (§7.1). That holds for permissions, but not for these two. The restored rig's
|
||||
// RunicNPC says it is already managed, by the site before this one, so the new
|
||||
// site adopts nothing (D244) and its first push empties the profiles. The import
|
||||
// never adopts a group's chat style. So the site holds them from the start and
|
||||
// pushes them, and the placements in the baseline spawn again.
|
||||
//
|
||||
// A chat style names a group the rig's import has not brought in yet, because
|
||||
// the seed runs first. The group is created for that server, and the import
|
||||
// then adopts the rig's title, rank and permissions into it. A style lists only
|
||||
// the fields that differ from BetterChat's defaults.
|
||||
//
|
||||
// **It runs on every seed, so it upserts.** Core seeds content on a database's
|
||||
// first run only, but calls this every time. The section's shape is checked
|
||||
// before anything is written, and a problem throws, which fails the seed. A
|
||||
// profile is checked by the NPC model as it is saved, as the admin page saves
|
||||
// one, so a profile the model refuses fails the seed too.
|
||||
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const npcShape = require('./model/npcs/npcProfile')
|
||||
const permApply = require('./model/permissions/permissions.apply')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const chatStyle = require('./model/permissions/chatStyle')
|
||||
const sidecar = require('./sidecarClient')
|
||||
const core = require('./core')
|
||||
|
||||
const log = core.logger('demo')
|
||||
|
||||
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
||||
const STEAM_ID = /^7656119\d{10}$/
|
||||
// Oxide group names as the permission model keeps them: trimmed and lower-cased.
|
||||
const GROUP_NAME = /^[a-z0-9_.-]{1,64}$/
|
||||
|
||||
/** BetterChat's defaults for the group, with the fixture's fields over them. */
|
||||
function fullStyle(group, style) {
|
||||
return { ...chatStyle.defaults(String(group || '')), ...(style && typeof style === 'object' ? style : {}) }
|
||||
}
|
||||
|
||||
/** Every problem with the section, as sentences. Empty when it is good to write. */
|
||||
function checkSection(data, accounts = {}) {
|
||||
const problems = []
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
|
||||
|
||||
const list = (key) => (data[key] === undefined ? [] : data[key])
|
||||
for (const key of ['servers', 'links', 'chat']) {
|
||||
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
|
||||
}
|
||||
const n = data.npcs
|
||||
if (n !== undefined) {
|
||||
if (!n || typeof n !== 'object' || Array.isArray(n)) problems.push('modules.rust.npcs must be an object')
|
||||
else {
|
||||
if (n.factions !== undefined && !Array.isArray(n.factions)) problems.push('modules.rust.npcs.factions must be an array')
|
||||
if (n.profiles !== undefined && !Array.isArray(n.profiles)) problems.push('modules.rust.npcs.profiles must be an array')
|
||||
}
|
||||
}
|
||||
if (problems.length) return problems
|
||||
|
||||
if (n && n.factions !== undefined) {
|
||||
const checked = npcShape.checkFactions(n.factions)
|
||||
if (!checked.ok) problems.push(`modules.rust.npcs.${checked.error}`)
|
||||
}
|
||||
;((n && n.profiles) || []).forEach((p, i) => {
|
||||
const at = `modules.rust.npcs.profiles[${i}]`
|
||||
if (!p || typeof p !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!npcShape.NAME_RULE.test(String(p.name || ''))) problems.push(`${at}.name must be 1 to 40 of a-z, 0-9, _ and -`)
|
||||
if (!p.body || typeof p.body !== 'object' || Array.isArray(p.body)) problems.push(`${at}.body must be an object`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('chat').forEach((c, i) => {
|
||||
const at = `modules.rust.chat[${i}]`
|
||||
if (!c || typeof c !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(c.serverId || ''))) problems.push(`${at}.serverId must be a server id`)
|
||||
if (!GROUP_NAME.test(String(c.group || ''))) problems.push(`${at}.group must be a group name`)
|
||||
const checked = chatStyle.validateStyle(fullStyle(c.group, c.style))
|
||||
if (!checked.ok) problems.push(`${at}.style: ${checked.errors.join(' ')}`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('servers').forEach((s, i) => {
|
||||
const at = `modules.rust.servers[${i}]`
|
||||
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(s.id || ''))) problems.push(`${at}.id must be lowercase letters, digits and hyphens`)
|
||||
if (typeof s.name !== 'string' || !s.name.trim()) problems.push(`${at}.name is required`)
|
||||
if (!/^https?:\/\//.test(String(s.sidecarBaseUrl || ''))) problems.push(`${at}.sidecarBaseUrl must be an http(s) URL`)
|
||||
if (typeof s.sidecarToken !== 'string' || !s.sidecarToken) problems.push(`${at}.sidecarToken is required`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('links').forEach((l, i) => {
|
||||
const at = `modules.rust.links[${i}]`
|
||||
if (!l || typeof l !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!accounts[l.username]) problems.push(`${at}.username must be one of the demo accounts`)
|
||||
if (!STEAM_ID.test(String(l.steamId || ''))) problems.push(`${at}.steamId must be a 17-digit Steam id`)
|
||||
if (l.serverId !== undefined && !SERVER_ID.test(String(l.serverId))) problems.push(`${at}.serverId must be a server id`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
/** The hook core calls: `onDemoSeed(data, { accounts })`. */
|
||||
async function onDemoSeed(data, { accounts = {} } = {}) {
|
||||
const problems = checkSection(data, accounts)
|
||||
if (problems.length) throw new Error(`the Rust demo seed has problems: ${problems.join('; ')}`)
|
||||
|
||||
for (const s of data.servers || []) {
|
||||
await serversDb.upsertServer({
|
||||
id: s.id,
|
||||
name: s.name.trim(),
|
||||
sidecarBaseUrl: s.sidecarBaseUrl,
|
||||
sidecarTokenEnc: servers.encryptToken(s.sidecarToken),
|
||||
protocol: s.protocol === undefined ? sidecar.PROTOCOL_VERSION : Number(s.protocol),
|
||||
enabled: s.enabled === undefined ? true : Boolean(s.enabled),
|
||||
sortOrder: s.sortOrder === undefined ? 0 : Number(s.sortOrder),
|
||||
})
|
||||
log.info('demo server ready', { server: s.id })
|
||||
}
|
||||
|
||||
// A link is a plain insert by design (D23), so the upsert is spelled out here:
|
||||
// the right owner already holds it, or it is moved to them. Only the seed may
|
||||
// move a link, and only to a demo account.
|
||||
for (const l of data.links || []) {
|
||||
const userId = accounts[l.username]
|
||||
const steamId = String(l.steamId)
|
||||
const existing = await linksDb.getBySteamId(steamId)
|
||||
if (existing && Number(existing.userId) === Number(userId)) continue
|
||||
if (existing) await linksDb.removeBySteamId(steamId)
|
||||
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
|
||||
log.info('demo link ready', { username: l.username })
|
||||
}
|
||||
|
||||
// After the servers: a profile is refused for a server the site does not have.
|
||||
const n = data.npcs || {}
|
||||
if (n.factions !== undefined) {
|
||||
await npcs.setFactions(n.factions)
|
||||
log.info('demo NPC factions ready', { pairs: n.factions.length })
|
||||
}
|
||||
if (n.profiles && n.profiles.length) {
|
||||
const existing = await npcsDb.listProfiles()
|
||||
for (const p of n.profiles) {
|
||||
const input = { name: p.name, body: p.body, servers: p.servers, allServers: p.allServers, killsScope: p.killsScope }
|
||||
const same = existing.find((e) => e.name === p.name && !e.replaced)
|
||||
if (same) await npcs.update(same.id, input)
|
||||
else await npcs.create(input)
|
||||
log.info('demo NPC profile ready', { profile: p.name })
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of data.chat || []) {
|
||||
const name = String(c.group)
|
||||
let group = await permApply.groupOn(name, c.serverId)
|
||||
if (!group) {
|
||||
const id = await permDb.insertGroup({ name, title: name })
|
||||
await permDb.setGroupServers(id, { allServers: false, servers: [c.serverId] })
|
||||
group = { id }
|
||||
}
|
||||
await permDb.setGroupChat(group.id, chatStyle.validateStyle(fullStyle(name, c.style)).fields)
|
||||
await permDb.markDirty(c.serverId)
|
||||
log.info('demo chat style ready', { server: c.serverId, group: name })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkSection, onDemoSeed }
|
||||
@@ -41,6 +41,7 @@ const core = require('./core')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const rewards = require('./eventRewards')
|
||||
const demo = require('./demo')
|
||||
|
||||
const log = core.logger('commands')
|
||||
|
||||
@@ -115,6 +116,9 @@ function checkConfirm(params, sentence) {
|
||||
/** The first command that names a placeholder, or null: only a reward has someone to fill one with. */
|
||||
const withPlaceholder = (lines) => lines.find((line) => PLACEHOLDER.test(line)) || null
|
||||
|
||||
/** A `multiple` param as a list: a bare value is a list of one, nothing is none. */
|
||||
const asList = (raw) => (raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw])
|
||||
|
||||
/** A command list's price: at least one, so the switchboard sees the dimension. */
|
||||
function pricedCommands(raw) {
|
||||
const checked = checkCommands(raw, { required: false })
|
||||
@@ -190,6 +194,11 @@ async function checkRun(params) {
|
||||
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
|
||||
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
|
||||
|
||||
// The demo's deny list (DEMO_MODE.md §3.4), at run: core asked at save
|
||||
// (demoRefuses), but a definition can predate the list.
|
||||
const denied = demo.deniedCommand([...commands.value, ...teardown.value])
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
|
||||
if (placeholder) {
|
||||
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
|
||||
@@ -324,6 +333,10 @@ const commandRun = {
|
||||
|
||||
revert: revertRun,
|
||||
|
||||
// The demo's deny list at save (MODULE_API 1.19.0, DM18). Core asks only on the
|
||||
// demo; null lets the step through.
|
||||
demoRefuses: (p) => demo.deniedCommand([...asList(p.commands), ...asList(p.teardown)]),
|
||||
|
||||
// The site cannot ask a server whether a command's effect is still there, so
|
||||
// a teardown not yet run is always in force: "I do not know" is never "gone".
|
||||
async reconcile({ resources }) {
|
||||
@@ -397,10 +410,15 @@ const commandReward = {
|
||||
|
||||
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
|
||||
|
||||
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const commands = checkCommands(params.commands)
|
||||
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
|
||||
|
||||
const denied = demo.deniedCommand(commands.value)
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const confirm = checkConfirm(params, REWARD_SURE)
|
||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||
|
||||
|
||||
@@ -168,6 +168,30 @@ module.exports = function register(ctx, api) {
|
||||
api.onIdentityLinking(links.checkSteamSignin)
|
||||
api.onIdentityLinked(links.linkFromSteamSignin)
|
||||
|
||||
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10). Core
|
||||
// asks before the users row goes: every Steam id the account holds is unlinked
|
||||
// through the player's own unlink, and the permission sync is marked dirty so
|
||||
// the withdrawn grants reach the game on the next tick. It cannot stop the
|
||||
// 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
|
||||
// demo's other locks are `demoLocks` in module.json and `demo.js`.
|
||||
const demoSeed = require('./demoSeed') // eslint-disable-line global-require
|
||||
api.onDemoSeed(demoSeed.onDemoSeed)
|
||||
|
||||
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
|
||||
// and must give back. Core's `core.lease` is the verb; these are the values it
|
||||
// may name and the four callables each ships. Every lease is targeted and the
|
||||
@@ -241,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,6 +174,71 @@ function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shaped file with every credential-shaped value hidden, for the public demo
|
||||
* (DEMO_MODE.md §3.5, DM14). The form's mask is not a lock (DM10), so on the
|
||||
* demo the value never leaves the module:
|
||||
*
|
||||
* - in `text`, each flagged string or number is spliced out for the
|
||||
* placeholder, from the end backwards so the spans stay true;
|
||||
* - in `fields`, a flagged field carries the placeholder and is `locked`,
|
||||
* so the form shows it read-only, and the save refuses an edit to it anyway.
|
||||
*
|
||||
* "Flagged" is the editor's own reading (`configEdit.isSecretKey`, inherited by
|
||||
* everything under a flagged key), so the demo hides exactly what the form masks.
|
||||
* A file that does not parse cannot be read for its secrets, so its text is
|
||||
* withheld whole: the lock fails closed.
|
||||
*/
|
||||
function redactSecrets(shaped, placeholder) {
|
||||
if (!shaped) return shaped
|
||||
|
||||
let root
|
||||
try {
|
||||
root = configEdit.scan(shaped.text)
|
||||
} catch {
|
||||
return { ...shaped, text: '', fields: null, redacted: true, withheld: 'This file does not parse, so it is not shown on the demo site.' }
|
||||
}
|
||||
|
||||
const spans = []
|
||||
const visit = (node, secret) => {
|
||||
if (node.type === 'object') {
|
||||
for (const child of node.children) visit(child, secret || configEdit.isSecretKey(child.key))
|
||||
} else if (node.type === 'array') {
|
||||
for (const child of node.children) visit(child, secret)
|
||||
} else if (secret && (node.type === 'number' || (node.type === 'string' && node.value !== ''))) {
|
||||
spans.push(node)
|
||||
}
|
||||
}
|
||||
visit(root, false)
|
||||
|
||||
let text = shaped.text
|
||||
for (const node of [...spans].sort((a, b) => b.start - a.start)) {
|
||||
text = text.slice(0, node.start) + JSON.stringify(placeholder) + text.slice(node.end)
|
||||
}
|
||||
|
||||
const fields = Array.isArray(shaped.fields)
|
||||
? shaped.fields.map((field) => {
|
||||
if (!field.secret) return field
|
||||
const leaf = field.type === 'number' || field.type === 'string'
|
||||
const { raw, ...rest } = field
|
||||
return {
|
||||
...rest,
|
||||
...(leaf && !(field.type === 'string' && field.value === '') ? { value: placeholder } : {}),
|
||||
locked: true,
|
||||
}
|
||||
})
|
||||
: shaped.fields
|
||||
|
||||
return { ...shaped, text, fields, redacted: true }
|
||||
}
|
||||
|
||||
/** Whether any edit names a credential-shaped field, as `applyEdits` marks them. */
|
||||
function editsTouchSecret(edits) {
|
||||
return (edits || []).some(
|
||||
(edit) => Array.isArray(edit && edit.pointer) && edit.pointer.some((step) => typeof step === 'string' && configEdit.isSecretKey(step)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which locked keys differ between two versions of a document.
|
||||
*
|
||||
@@ -290,6 +355,8 @@ module.exports = {
|
||||
isPlausiblePath,
|
||||
shapeCatalogue,
|
||||
shapeFile,
|
||||
redactSecrets,
|
||||
editsTouchSecret,
|
||||
pluginOf,
|
||||
isBridgeConfig,
|
||||
lockedKeysFor,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
const core = require('../../core')
|
||||
const db = require('./links.db')
|
||||
const engagement = require('../../engagement/emit')
|
||||
const permissionsDb = require('../permissions/permissions.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const serversDb = require('../servers/servers.db')
|
||||
const sidecar = require('../../sidecarClient')
|
||||
@@ -351,6 +352,47 @@ async function linkFromSteamSignin({ userId, provider, subject, displayName }) {
|
||||
engagement.linked({ userId, steamId, name: displayName || null })
|
||||
}
|
||||
|
||||
// ── The website account is being deleted (MODULE_API 1.20.0) ──────────────
|
||||
//
|
||||
// ACCOUNT_DELETION.md §4.4. Core asks before the users row goes, while the
|
||||
// person's links can still be read (AD10). The cascade on `rust_account_links`
|
||||
// would remove the rows anyway; calling the real unlink keeps what a cascade
|
||||
// skips — the `rust.account.unlink` entry staff read and the prompt Teams
|
||||
// re-sync — so the game sees exactly what a player's own unlink produces.
|
||||
|
||||
/**
|
||||
* Core's `api.onUserDeleting`: unlink every Steam id this account holds.
|
||||
*
|
||||
* The same `unlinkOwned` the player's Rust page calls, one link at a time, each
|
||||
* logged as `rust.account.unlink` with `reason: 'account deleted'`. Then every
|
||||
* server's permission sync is marked dirty: the desired set reaches a grant's
|
||||
* Steam id through the link, so with the links gone the grants have already
|
||||
* left it, and the next tick pushes the withdrawal instead of waiting for the
|
||||
* digest check.
|
||||
*
|
||||
* Nothing here waits on a game. The link is the site's own record and the plugin
|
||||
* keeps none (the header above), so there is never pending work: what this does
|
||||
* not finish, the cascade does.
|
||||
*/
|
||||
async function unlinkForDeletion({ userId, deletedBy }) {
|
||||
const held = await db.listForUser(userId)
|
||||
let removed = 0
|
||||
for (const row of held) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await unlinkOwned(row.steamId, userId)) {
|
||||
removed += 1
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await core.activity.log({
|
||||
userId,
|
||||
action: 'rust.account.unlink',
|
||||
detail: { steamId: row.steamId, reason: 'account deleted', deletedBy },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (removed > 0) await permissionsDb.markDirty(null)
|
||||
log.info('rust links removed before an account was deleted', { userId, deletedBy, removed })
|
||||
}
|
||||
|
||||
/** The admin panel's read: every link this user holds, with per-server totals. */
|
||||
async function forAdmin(userId) {
|
||||
const links = await db.listForUserWithPlayer(userId)
|
||||
@@ -393,5 +435,6 @@ module.exports = {
|
||||
unlinkFromGame,
|
||||
checkSteamSignin,
|
||||
linkFromSteamSignin,
|
||||
unlinkForDeletion,
|
||||
forAdmin,
|
||||
}
|
||||
|
||||
@@ -203,7 +203,9 @@ async function listForAdmin(now = Date.now()) {
|
||||
// The public shape first, so the admin-only fields below cannot be
|
||||
// overwritten by a key the public shape happens to share.
|
||||
...shapePublic(row, state, now),
|
||||
sidecarBaseUrl: row.sidecarBaseUrl,
|
||||
// Left out on the public demo, so the screen gives nothing away that helps
|
||||
// someone reach the sidecar (DEMO_MODE.md §3.5); editing the link is locked.
|
||||
...(core.demoMode ? {} : { sidecarBaseUrl: row.sidecarBaseUrl }),
|
||||
hasToken: Boolean(row.sidecarTokenEnc),
|
||||
protocol: Number(row.protocol),
|
||||
enabled: Boolean(row.enabled),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const configEdit = require('../../configEdit')
|
||||
const demo = require('../../demo')
|
||||
const db = require('../../model/config/config.db')
|
||||
const model = require('../../model/config/config.model')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
@@ -87,7 +88,32 @@ async function listFiles(req, res) {
|
||||
return res.status(502).json({ message: refusalMessage(reply.data) })
|
||||
}
|
||||
|
||||
return res.json(model.shapeCatalogue(reply.data))
|
||||
return res.json(forDemo(model.shapeCatalogue(reply.data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue without the bridge's own files, on the demo (DEMO_MODE.md §3.5,
|
||||
* DM14): they hold its link to the sidecar, so they are not listed, read or
|
||||
* written. Off the demo the catalogue is untouched.
|
||||
*/
|
||||
function forDemo(catalogue) {
|
||||
if (!demo.isDemo() || !catalogue) return catalogue
|
||||
return {
|
||||
...catalogue,
|
||||
plugins: catalogue.plugins
|
||||
.filter((group) => !demo.isBridgePlugin(group.plugin, catalogue.self))
|
||||
.map((group) => ({ ...group, files: group.files.filter((file) => !demo.isBridgePath(file.path, catalogue.self)) })),
|
||||
}
|
||||
}
|
||||
|
||||
/** A shaped file as the caller may see it: secrets hidden on the demo. */
|
||||
function visible(shaped) {
|
||||
return demo.isDemo() ? model.redactSecrets(shaped, demo.REDACTED) : shaped
|
||||
}
|
||||
|
||||
/** The demo's refusal for the bridge's config, in one place. */
|
||||
function refuseBridge(res) {
|
||||
return demo.refuse(res, 'The bridge plugin’s configuration is not shown on the demo site.')
|
||||
}
|
||||
|
||||
/** One file: its text, and the reading of it the form is drawn from. */
|
||||
@@ -98,6 +124,10 @@ async function readFile(req, res) {
|
||||
return res.status(400).json({ message: 'That is not a configuration path' })
|
||||
}
|
||||
|
||||
// Before the host is asked, by the bridge's own name; and again below, by the
|
||||
// name its catalogue gives, in case the two ever differ.
|
||||
if (demo.isDemo() && demo.isBridgePath(path, null)) return refuseBridge(res)
|
||||
|
||||
const server = await serverOr404(req, res)
|
||||
if (!server) return undefined
|
||||
|
||||
@@ -116,7 +146,8 @@ async function readFile(req, res) {
|
||||
}
|
||||
|
||||
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
|
||||
return res.json(model.shapeFile(fileReply.data, { self }))
|
||||
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
|
||||
return res.json(visible(model.shapeFile(fileReply.data, { self })))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +166,16 @@ async function writeFile(req, res) {
|
||||
return res.status(400).json({ message: 'That is not a configuration path' })
|
||||
}
|
||||
|
||||
if (demo.isDemo()) {
|
||||
if (demo.isBridgePath(path, null)) return refuseBridge(res)
|
||||
// A raw document sent back from the demo holds the placeholders, and would
|
||||
// write them over the real values. The form splices only what changed.
|
||||
if (tier === 'raw') return demo.refuse(res, 'Raw JSON saves are turned off on the demo site. Use the form.')
|
||||
if (model.editsTouchSecret(req.body.edits)) {
|
||||
return demo.refuse(res, 'Credential fields are read-only on the demo site.')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await serverOr404(req, res)
|
||||
if (!server) return undefined
|
||||
|
||||
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
|
||||
|
||||
const onDisk = current.data
|
||||
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
|
||||
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
|
||||
const locked = model.lockedKeysFor(path, self)
|
||||
|
||||
// The browser's version against what is on the host right now. The plugin
|
||||
@@ -160,7 +202,7 @@ async function writeFile(req, res) {
|
||||
if (String(req.body.version || '') !== String(onDisk.version)) {
|
||||
return res.status(409).json({
|
||||
message: 'That file changed on the server since you opened it',
|
||||
current: model.shapeFile(onDisk, { self }),
|
||||
current: visible(model.shapeFile(onDisk, { self })),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -287,7 +329,9 @@ async function writeFile(req, res) {
|
||||
/** What has been written to this server's configuration lately, and by whom. */
|
||||
async function history(req, res) {
|
||||
try {
|
||||
return res.json({ writes: await db.recentWrites(req.params.serverId, req.query.limit) })
|
||||
const writes = await db.recentWrites(req.params.serverId, req.query.limit)
|
||||
// The bridge's own writes are left out on the demo, as its files are (§3.5).
|
||||
return res.json({ writes: demo.isDemo() ? writes.filter((w) => !isBridgeWrite(w)) : writes })
|
||||
} catch (err) {
|
||||
log.error('failed to read the configuration history', { error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to read the configuration history' })
|
||||
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
|
||||
try {
|
||||
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
|
||||
if (!write) return res.status(404).json({ message: 'No such configuration write' })
|
||||
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
|
||||
|
||||
return res.json({ write })
|
||||
} catch (err) {
|
||||
@@ -313,6 +358,11 @@ async function writeStatus(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an audit row is a write to the bridge's own config. */
|
||||
function isBridgeWrite(write) {
|
||||
return demo.isBridgePlugin(write.plugin, null) || demo.isBridgePath(write.path, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* One audit row, plus the activity entry core owns. Never lets a logging failure
|
||||
* fail a save. Returns the row's id, or null when it could not be written.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
const demo = require('../../demo')
|
||||
|
||||
const backpacks = require('../../model/backpacks/backpacks.model')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
@@ -54,6 +55,10 @@ async function removeLink(req, res) {
|
||||
const { steamId } = req.params
|
||||
const userId = req.params.id
|
||||
|
||||
// On the demo the player's own unlink is locked by demoLocks; this route serves
|
||||
// under core's users mount, which a module may not lock by route, so it asks here.
|
||||
if (demo.isDemo()) return demo.refuse(res)
|
||||
|
||||
try {
|
||||
const removed = await links.unlinkOwned(steamId, userId)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -104,6 +110,8 @@ function fakeCtx(overrides = {}) {
|
||||
accountChangeLimiter: (req, res, next) => next(),
|
||||
},
|
||||
site: { baseUrl: 'http://localhost:5173' },
|
||||
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
|
||||
demoMode: false,
|
||||
...rest,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
@@ -169,6 +177,13 @@ function fakeApi() {
|
||||
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
|
||||
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
||||
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
|
||||
// MODULE_API 1.18.0 (the public demo's seed, DM15).
|
||||
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
|
||||
|
||||
490
server/test/demo.test.js
Normal file
490
server/test/demo.test.js
Normal file
@@ -0,0 +1,490 @@
|
||||
// ── The public demo's module-side locks (website/DEMO_MODE.md §3.4, §3.5, §7.3) ──
|
||||
//
|
||||
// Everything a route list cannot lock, behind `ctx.demoMode` (MODULE_API
|
||||
// 1.18.0): the console-command deny list (DM11) at save through core's
|
||||
// `demoRefuses` (1.19.0, DM18) and at run; the bridge's config, neither read nor
|
||||
// written (DM14); secrets in every other config, redacted and read-only; the
|
||||
// sidecar URL left off the Servers view; and the seed hook (DM15). Each lock is
|
||||
// held twice: on the demo it refuses, and off it nothing changes.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore({ demo = true, select = () => [] } = {}) {
|
||||
const queries = []
|
||||
require('../core')._reset()
|
||||
require('../core').init(
|
||||
fakeCtx({
|
||||
demoMode: demo,
|
||||
db: {
|
||||
query: (sql, params) => {
|
||||
const flat = sql.trim().replace(/\s+/g, ' ')
|
||||
queries.push({ sql: flat, params })
|
||||
const verb = flat.split(' ')[0].toUpperCase()
|
||||
if (verb === 'SELECT') return Promise.resolve(select(flat, params))
|
||||
return Promise.resolve({ affectedRows: 1, insertId: 1 })
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
return queries
|
||||
}
|
||||
|
||||
function fakeRes() {
|
||||
const res = { statusCode: 200, body: null }
|
||||
res.status = (code) => {
|
||||
res.statusCode = code
|
||||
return res
|
||||
}
|
||||
res.json = (body) => {
|
||||
res.body = body
|
||||
return res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4) ───────────────────────────────────
|
||||
|
||||
test('the deny list compares the first word, normalised', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const line of [
|
||||
'quit', 'QUIT', ' quit ', 'global.quit', 'restart 60 "bye"',
|
||||
'oxide.unload RunicGateway', 'o.unload RunicNPC', 'oxide.reload *', 'o.reload Kits', 'global.oxide.reload x',
|
||||
'rcon.password hunter2', 'RCON.web 0', 'fps.limit 5', 'server.tickrate 1',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), true, line)
|
||||
}
|
||||
for (const line of [
|
||||
'say hello', 'server.writecfg', 'oxide.grant group default kits.use', 'kick someone', 'rbe 1',
|
||||
'quitter', 'restarts', 'oxide.load Foo', 'entity.spawn bear', 'weather.rain 1', '',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), false, line)
|
||||
}
|
||||
assert.equal(demo.commandWord('Global.O.Unload X'), 'oxide.unload')
|
||||
})
|
||||
|
||||
test('the deny list refuses nothing off the demo', () => {
|
||||
withCore({ demo: false })
|
||||
assert.equal(require('../demo').deniedCommand(['quit']), null)
|
||||
})
|
||||
|
||||
test('both command actions refuse a denied command at save, through demoRefuses', () => {
|
||||
withCore()
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
assert.equal(run.demoRefuses({ commands: ['say hi', 'quit'] }), '"quit" is turned off on the demo site.')
|
||||
// A teardown runs at the end of the event, so it is checked as well.
|
||||
assert.equal(run.demoRefuses({ commands: ['rbe 1'], teardown: ['o.unload RunicGateway'] }), '"o.unload RunicGateway" is turned off on the demo site.')
|
||||
assert.equal(run.demoRefuses({ commands: 'say hi' }), null)
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100', 'rcon.password x'] }), '"rcon.password x" is turned off on the demo site.')
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100'] }), null)
|
||||
})
|
||||
|
||||
test('a denied command is refused at run as well, permanently, before the rig is asked', async () => {
|
||||
withCore()
|
||||
const sidecar = require('../sidecarClient')
|
||||
let asked = 0
|
||||
sidecar.commandsRun = async () => { asked += 1; return { ok: true, data: { kind: 'command.ran', ran: 1 } } }
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
const ran = await run.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', commands: ['quit'], confirm: true } })
|
||||
assert.deepEqual(ran, { ok: false, retry: false, error: '"quit" is turned off on the demo site.' })
|
||||
|
||||
const rewarded = await reward.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', recipients: 'linked', commands: ['restart'], confirm: true } })
|
||||
assert.deepEqual(rewarded, { ok: false, retry: false, error: '"restart" is turned off on the demo site.' })
|
||||
assert.equal(asked, 0)
|
||||
})
|
||||
|
||||
// ── The bridge's own config (§3.5) ─────────────────────────────────────────
|
||||
|
||||
test('a bridge path is recognised however it is spelled', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const p of ['RunicGateway.json', './RunicGateway.json', 'runicgateway.json', 'RunicGateway/extra.json', ' RunicGateway.json ', 'RunicGateway./x.json', 'Bridge.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), true, p)
|
||||
}
|
||||
for (const p of ['ZoneManager.json', 'Kits/RunicGateway.json', 'RunicGatewayExtra.json', '@data/RaidableBases/Profiles/a.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), false, p)
|
||||
}
|
||||
})
|
||||
|
||||
const SECRET_FILE = `{
|
||||
"Discord Webhook": "https://discord.example/hook/abc",
|
||||
"Api Key": 123456789,
|
||||
"Empty Token": "",
|
||||
"Use Auth": true,
|
||||
"Credentials": { "User": "admin", "List": ["one", "two"] },
|
||||
"Gather": { "Wood": 1.0 },
|
||||
"Enabled": true
|
||||
}`
|
||||
|
||||
function stubSidecar({ file = SECRET_FILE, self = 'RunicGateway' } = {}) {
|
||||
const sidecar = require('../sidecarClient')
|
||||
const calls = []
|
||||
sidecar.configFile = async (server, asked) => {
|
||||
calls.push(['read', asked])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.file', path: asked, text: file, version: `v-${asked}`, bytes: file.length } }
|
||||
}
|
||||
sidecar.configFiles = async () => {
|
||||
calls.push(['list'])
|
||||
return {
|
||||
ok: true,
|
||||
status: 'ok',
|
||||
data: {
|
||||
kind: 'config.catalogue',
|
||||
root: '/home/container/oxide/config',
|
||||
self,
|
||||
files: [
|
||||
{ path: 'Discord.json', bytes: 40, editable: true, plugin: 'Discord' },
|
||||
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
|
||||
],
|
||||
plugins: [{ name: 'Discord', title: 'Discord', version: '1.0.0' }, { name: 'RunicGateway', title: 'Runic Gateway', version: '1.0.0' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
sidecar.configWrite = async (server, body) => {
|
||||
calls.push(['write', body])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function stubServer() {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 14 }
|
||||
require('../model/servers/servers.db').getServer = async () => row
|
||||
require('../model/servers/servers.model').withToken = () => ({ id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' })
|
||||
}
|
||||
|
||||
const controller = () => require('../router/admin/config.controller')
|
||||
|
||||
test('the file list leaves the bridge out on the demo, and keeps it off the demo', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.plugins.map((p) => p.plugin), ['Discord'])
|
||||
|
||||
withCore({ demo: false })
|
||||
const off = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, off)
|
||||
assert.deepEqual(off.body.plugins.map((p) => p.plugin), ['Discord', 'RunicGateway'])
|
||||
})
|
||||
|
||||
test('the bridge\'s config cannot be read on the demo, and the host is never asked for it', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
for (const path of ['RunicGateway.json', './RunicGateway.json']) {
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
|
||||
assert.equal(res.statusCode, 403, path)
|
||||
assert.equal(res.body.code, 'DEMO_LOCKED')
|
||||
}
|
||||
assert.deepEqual(calls, [])
|
||||
|
||||
// Named by the catalogue's own word for the plugin, when that differs.
|
||||
const renamed = stubSidecar({ self: 'Bridge' })
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Bridge.json' } }, res)
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.ok(renamed.some(([kind]) => kind === 'list'))
|
||||
})
|
||||
|
||||
test('another plugin\'s secrets are redacted in the text and the form, and made read-only', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
const { text, fields } = res.body
|
||||
assert.doesNotMatch(text, /discord\.example|123456789|"admin"|"one"/)
|
||||
assert.match(text, /"Discord Webhook": "\(hidden on the demo site\)"/)
|
||||
// Still JSON, and the values that are not credentials are untouched.
|
||||
const parsed = JSON.parse(text)
|
||||
assert.equal(parsed['Empty Token'], '')
|
||||
assert.equal(parsed['Use Auth'], true)
|
||||
assert.equal(parsed.Gather.Wood, 1)
|
||||
assert.match(text, /"Wood": 1\.0/)
|
||||
|
||||
const byPath = Object.fromEntries(fields.map((f) => [f.path, f]))
|
||||
assert.equal(byPath['Discord Webhook'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Discord Webhook'].locked, true)
|
||||
assert.equal(byPath['Api Key'].value, '(hidden on the demo site)')
|
||||
assert.equal('raw' in byPath['Api Key'], false)
|
||||
assert.equal(byPath['Credentials.List[0]'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Gather.Wood'].locked, false)
|
||||
assert.equal(res.body.redacted, true)
|
||||
})
|
||||
|
||||
test('off the demo the file is returned as it is', async () => {
|
||||
withCore({ demo: false })
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
assert.equal(res.body.text, SECRET_FILE)
|
||||
assert.equal(res.body.redacted, undefined)
|
||||
})
|
||||
|
||||
test('a file that does not parse is withheld whole on the demo', () => {
|
||||
withCore()
|
||||
const model = require('../model/config/config.model')
|
||||
const shaped = model.shapeFile({ path: 'Broken.json', version: 'v', text: '{ "Api Key": "abc", ' })
|
||||
const out = model.redactSecrets(shaped, '(hidden)')
|
||||
assert.equal(out.text, '')
|
||||
assert.match(out.withheld, /does not parse/)
|
||||
})
|
||||
|
||||
test('on the demo a save refuses the bridge, raw JSON and a credential field, before the host is asked', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const save = async (body) => {
|
||||
const res = fakeRes()
|
||||
await controller().writeFile({ params: { serverId: 'main' }, body: { version: 'v-Discord.json', ...body } }, res)
|
||||
return res
|
||||
}
|
||||
|
||||
const bridge = await save({ path: 'RunicGateway.json', edits: [{ pointer: ['QueueCap'], raw: '1' }] })
|
||||
assert.equal(bridge.statusCode, 403)
|
||||
const raw = await save({ path: 'Discord.json', text: SECRET_FILE })
|
||||
assert.equal(raw.statusCode, 403)
|
||||
assert.match(raw.body.message, /Raw JSON saves/)
|
||||
const secret = await save({ path: 'Discord.json', edits: [{ pointer: ['Discord Webhook'], value: 'https://evil' }] })
|
||||
assert.equal(secret.statusCode, 403)
|
||||
const nested = await save({ path: 'Discord.json', edits: [{ pointer: ['Credentials', 'User'], value: 'x' }] })
|
||||
assert.equal(nested.statusCode, 403)
|
||||
for (const r of [bridge, raw, secret, nested]) assert.equal(r.body.code, 'DEMO_LOCKED')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('on the demo a form edit to an ordinary field saves, and leaves the secrets on disk alone', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-Discord.json', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 200)
|
||||
const [, body] = calls.find(([kind]) => kind === 'write')
|
||||
assert.match(body.files[0].text, /discord\.example\/hook\/abc/)
|
||||
assert.match(body.files[0].text, /"Enabled": false/)
|
||||
})
|
||||
|
||||
test('a conflict\'s current file is redacted too', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.doesNotMatch(res.body.current.text, /discord\.example/)
|
||||
})
|
||||
|
||||
test('the write history leaves the bridge\'s writes out, and one of them cannot be read', async () => {
|
||||
const rows = [
|
||||
{ id: 2, serverId: 'main', path: 'RunicGateway.json', plugin: 'RunicGateway', outcome: 'applied', changes: null },
|
||||
{ id: 1, serverId: 'main', path: 'Discord.json', plugin: 'Discord', outcome: 'applied', changes: null },
|
||||
]
|
||||
withCore({ select: (sql, params) => (sql.includes('WHERE server_id = ? AND id = ?') ? rows.filter((r) => r.id === params[1]) : rows) })
|
||||
const res = fakeRes()
|
||||
await controller().history({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.writes.map((w) => w.id), [1])
|
||||
|
||||
const one = fakeRes()
|
||||
const db = require('../model/config/config.db')
|
||||
db.getWrite = async () => rows[0]
|
||||
await controller().writeStatus({ params: { serverId: 'main', writeId: '2' } }, one)
|
||||
assert.equal(one.statusCode, 403)
|
||||
assert.equal(one.body.code, 'DEMO_LOCKED')
|
||||
})
|
||||
|
||||
// ── The Servers view (§3.5) ────────────────────────────────────────────────
|
||||
|
||||
test('the admin server list leaves the sidecar URL out on the demo, and keeps hasToken', async () => {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://10.0.0.5:7790', sidecarTokenEnc: 'enc:t', protocol: 14, enabled: 1, sortOrder: 0 }
|
||||
for (const demoMode of [true, false]) {
|
||||
withCore({ demo: demoMode })
|
||||
const db = require('../model/servers/servers.db')
|
||||
db.listServers = async () => [row]
|
||||
db.listState = async () => []
|
||||
const [shaped] = await require('../model/servers/servers.model').listForAdmin()
|
||||
assert.equal('sidecarBaseUrl' in shaped, !demoMode)
|
||||
assert.equal(shaped.hasToken, true)
|
||||
assert.equal(JSON.stringify(shaped).includes('10.0.0.5'), !demoMode)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The seed hook (§7.3, DM15) ─────────────────────────────────────────────
|
||||
|
||||
const SECTION = {
|
||||
servers: [{ id: 'main', name: 'Demo rig', sidecarBaseUrl: 'http://rig:7790', sidecarToken: 'tok' }],
|
||||
links: [{ username: 'demo-player', steamId: '76561190000000001', serverId: 'main' }],
|
||||
}
|
||||
|
||||
test('the seed section is checked whole before anything is written', async () => {
|
||||
const queries = withCore()
|
||||
const seed = require('../demoSeed')
|
||||
const accounts = { 'demo-player': 3 }
|
||||
assert.deepEqual(seed.checkSection(SECTION, accounts), [])
|
||||
const problems = seed.checkSection({
|
||||
servers: [{ id: 'Main', name: '', sidecarBaseUrl: 'rig', sidecarToken: '' }],
|
||||
links: [{ username: 'someone', steamId: '123', serverId: 'Other' }],
|
||||
}, accounts)
|
||||
assert.equal(problems.length, 7)
|
||||
await assert.rejects(seed.onDemoSeed({ servers: 'x' }, { accounts }), /must be an array/)
|
||||
assert.deepEqual(queries, [])
|
||||
})
|
||||
|
||||
test('the seed upserts the server with an encrypted token, and links demo-player', async () => {
|
||||
const queries = withCore()
|
||||
await require('../demoSeed').onDemoSeed(SECTION, { accounts: { 'demo-player': 3 } })
|
||||
const upsert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_servers'))
|
||||
assert.match(upsert.sql, /ON DUPLICATE KEY UPDATE/)
|
||||
assert.deepEqual(upsert.params.slice(0, 4), ['main', 'Demo rig', 'http://rig:7790', 'enc:tok'])
|
||||
const link = queries.find((q) => q.sql.startsWith('INSERT INTO rust_account_links'))
|
||||
assert.deepEqual(link.params, ['76561190000000001', 3, null, 'main'])
|
||||
})
|
||||
|
||||
test('a re-seed leaves a link the right owner holds, and moves one held by someone else', async () => {
|
||||
let owner = 3
|
||||
const queries = withCore({ select: (sql) => (sql.includes('rust_account_links') ? [{ steamId: '76561190000000001', userId: owner }] : []) })
|
||||
const seed = require('../demoSeed')
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
assert.equal(queries.some((q) => /^(INSERT|DELETE)/.test(q.sql)), false)
|
||||
|
||||
owner = 9
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
const writes = queries.filter((q) => /^(INSERT|DELETE)/.test(q.sql)).map((q) => q.sql.split(' ')[0])
|
||||
assert.deepEqual(writes, ['DELETE', 'INSERT'])
|
||||
})
|
||||
|
||||
// ── NPCs and chat styles from the seed (DM26) ──────────────────────────────
|
||||
|
||||
const NPC_SECTION = {
|
||||
npcs: {
|
||||
factions: [{ a: 'bandits', b: 'guards', relation: 'allied' }],
|
||||
profiles: [
|
||||
{ name: 'bandit', servers: ['main'], body: { kits: ['rnpc_raider'], role: 'roamer' } },
|
||||
{ name: 'guard', servers: ['main'], body: { kits: ['rnpc_campguard'], role: 'sentry' } },
|
||||
],
|
||||
},
|
||||
chat: [
|
||||
{ serverId: 'main', group: 'vip', style: { Title: '[VIP]', TitleColor: '#ffaa00' } },
|
||||
{ serverId: 'main', group: 'moderator', style: { Title: '[MOD]' } },
|
||||
],
|
||||
}
|
||||
|
||||
/** Stubs the models the seed writes NPCs and chat styles through; returns the calls. */
|
||||
function stubSeedModels({ profiles = [], groups = {} } = {}) {
|
||||
const calls = []
|
||||
const npcs = require('../model/npcs/npcs.model')
|
||||
const npcsDb = require('../model/npcs/npcs.db')
|
||||
const apply = require('../model/permissions/permissions.apply')
|
||||
const permDb = require('../model/permissions/permissions.db')
|
||||
npcs.setFactions = async (f) => calls.push(['setFactions', f])
|
||||
npcs.create = async (input) => calls.push(['create', input.name, input.servers])
|
||||
npcs.update = async (id, input) => calls.push(['update', id, input.name])
|
||||
npcsDb.listProfiles = async () => profiles
|
||||
apply.groupOn = async (name) => groups[name] || null
|
||||
permDb.insertGroup = async ({ name }) => {
|
||||
calls.push(['insertGroup', name])
|
||||
return 50
|
||||
}
|
||||
permDb.setGroupServers = async (id, s) => calls.push(['setGroupServers', id, s.servers])
|
||||
permDb.setGroupChat = async (id, style) => calls.push(['setGroupChat', id, style.Title, style.TitleColor, Object.keys(style).length])
|
||||
permDb.markDirty = async (scope) => calls.push(['markDirty', scope])
|
||||
return calls
|
||||
}
|
||||
|
||||
test('the NPC and chat parts of the seed section are checked before anything is written', async () => {
|
||||
withCore()
|
||||
const seed = require('../demoSeed')
|
||||
assert.deepEqual(seed.checkSection(NPC_SECTION, {}), [])
|
||||
const problems = seed.checkSection({
|
||||
npcs: { factions: [{ a: 'scientists', b: 'animals', relation: 'hostile' }], profiles: [{ name: 'Bad Name', body: [] }] },
|
||||
chat: [{ serverId: 'Main', group: '', style: { Title: '[VIP]', Nope: 1 } }],
|
||||
}, {})
|
||||
assert.equal(problems.length, 6, problems.join(' | '))
|
||||
assert.deepEqual(seed.checkSection({ npcs: [] }, {}), ['modules.rust.npcs must be an object'])
|
||||
const calls = stubSeedModels()
|
||||
await assert.rejects(seed.onDemoSeed({ chat: [{ serverId: 'main', group: 'vip', style: { Priority: 'x' } }] }, { accounts: {} }), /style/)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('the seed sets the faction table, saves each profile, and styles each group', async () => {
|
||||
withCore()
|
||||
const calls = stubSeedModels({ profiles: [{ id: 7, name: 'bandit', replaced: false }], groups: { vip: { id: 13 } } })
|
||||
await require('../demoSeed').onDemoSeed(NPC_SECTION, { accounts: {} })
|
||||
assert.deepEqual(calls, [
|
||||
['setFactions', NPC_SECTION.npcs.factions],
|
||||
['update', 7, 'bandit'], // already there: updated, not created twice
|
||||
['create', 'guard', ['main']],
|
||||
['setGroupChat', 13, '[VIP]', '#ffaa00', 12], // all twelve fields, the defaults filled in
|
||||
['markDirty', 'main'],
|
||||
['insertGroup', 'moderator'], // the rig's import has not brought it in yet
|
||||
['setGroupServers', 50, ['main']],
|
||||
['setGroupChat', 50, '[MOD]', '#55aaff', 12],
|
||||
['markDirty', 'main'],
|
||||
])
|
||||
})
|
||||
|
||||
// ── The manifest (§3.2) ────────────────────────────────────────────────────
|
||||
|
||||
test('module.json locks the two rig-link routes and the player Steam unlink, and needs a core that asks demoRefuses', () => {
|
||||
const manifest = require('../../module.json')
|
||||
assert.deepEqual(manifest.demoLocks, [
|
||||
{ method: 'PUT', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
|
||||
])
|
||||
// A floor, not an exact value: later contract members raise it (1.20.0 for
|
||||
// account deletion), and what this test holds is that it never drops below the
|
||||
// core that asks demoRefuses.
|
||||
const [major, minor] = manifest.coreApi.replace(/^\^/, '').split('.').map(Number)
|
||||
assert.ok(major === 1 && minor >= 19, `coreApi ${manifest.coreApi} is at least ^1.19.0`)
|
||||
})
|
||||
|
||||
// ── The staff Steam unlink (W4) ────────────────────────────────────────────
|
||||
|
||||
test('staff cannot sever a Steam link on the demo, and the link is never touched', async () => {
|
||||
for (const demoMode of [true, false]) {
|
||||
withCore({ demo: demoMode })
|
||||
const links = require('../model/links/links.model')
|
||||
const asked = []
|
||||
links.unlinkOwned = async (steamId, userId) => {
|
||||
asked.push([steamId, userId])
|
||||
return true
|
||||
}
|
||||
const res = fakeRes()
|
||||
await require('../router/admin/usersRust.controller').removeLink(
|
||||
{ params: { id: '3', steamId: '76561190000000001' }, user: { id: 1 } },
|
||||
res,
|
||||
)
|
||||
if (demoMode) {
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.equal(res.body.code, 'DEMO_LOCKED')
|
||||
assert.deepEqual(asked, [])
|
||||
} else {
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body, { unlinked: true })
|
||||
assert.deepEqual(asked, [['76561190000000001', '3']])
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -92,6 +92,12 @@ test('registers both identity hooks, so a Steam sign-in link is a Rust link (D39
|
||||
assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin)
|
||||
})
|
||||
|
||||
test('registers the account-deletion hook, so a deleted account is unlinked first (AD10)', () => {
|
||||
const { api } = register()
|
||||
const links = require('../model/links/links.model')
|
||||
assert.strictEqual(api.record.hooks.onUserDeleting, links.unlinkForDeletion)
|
||||
})
|
||||
|
||||
test('the manifest declares what the loader requires', () => {
|
||||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||
|
||||
@@ -472,6 +472,42 @@ test('the linked hook writes nothing for a link that is already there, or for an
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||||
})
|
||||
|
||||
// ── The website account is being deleted (MODULE_API 1.20.0, AD10) ─────────
|
||||
|
||||
test('a deleted account loses every Steam link through the player’s own unlink, each logged', async () => {
|
||||
const { ctx, queries } = withCore({ select: [[{ steamId: '7656', userId: 4 }, { steamId: STEAM, userId: 4 }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
await links.unlinkForDeletion({ userId: 4, deletedBy: 'self' })
|
||||
|
||||
// The same statement `unlinkOwned` uses, scoped by user, once per link.
|
||||
const dels = queries.filter((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
|
||||
assert.deepEqual(dels.map((q) => q.params), [['7656', 4], [STEAM, 4]])
|
||||
|
||||
// What a cascade would skip: one entry per link, and the Teams re-sync.
|
||||
assert.deepEqual(ctx.activity.log.calls.map((c) => c[0]), [
|
||||
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: '7656', reason: 'account deleted', deletedBy: 'self' } },
|
||||
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: STEAM, reason: 'account deleted', deletedBy: 'self' } },
|
||||
])
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 2)
|
||||
|
||||
// Every server is marked dirty, so the withdrawn grants go on the next tick.
|
||||
const dirty = queries.filter((q) => /UPDATE rust_perm_sync SET dirty = 1/.test(q.sql))
|
||||
assert.equal(dirty.length, 1)
|
||||
assert.ok(!/WHERE/.test(dirty[0].sql), 'the whole fleet, not one server')
|
||||
})
|
||||
|
||||
test('an account with no Steam link deletes without touching the game or the sync', async () => {
|
||||
const { ctx, queries } = withCore({ select: [[]] })
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
await links.unlinkForDeletion({ userId: 4, deletedBy: 'staff' })
|
||||
|
||||
assert.equal(queries.filter((q) => !q.sql.trim().toUpperCase().startsWith('SELECT')).length, 0)
|
||||
assert.equal(ctx.activity.log.calls.length, 0)
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a /link that lands between core’s check and the hook keeps its link; the hook does not throw', async () => {
|
||||
const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' })
|
||||
const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,61 @@
|
||||
{
|
||||
"paths": {
|
||||
"/api/v1/admin/rust/chat/{id}/hide": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Hide a chat line for everyone",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The chat line’s event id"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Hidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such chat line"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/chat/{id}/unhide": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Show a hidden chat line again",
|
||||
"description": "Undoes a hide for everyone, whether it was made here or from the Reports queue. Recorded in the activity log. Admins and moderators.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The chat line’s event id"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Shown again"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such chat line"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/config/{serverId}/file": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3203,7 +3259,7 @@
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "Recent events on one Rust server",
|
||||
"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.",
|
||||
"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`.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
|
||||
Reference in New Issue
Block a user