Compare commits
14 Commits
5ce711048c
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| 47756d392a | |||
| e54ae3afb9 | |||
| b1abd87c3d | |||
| f35e70e7d3 | |||
| 43147b796a | |||
| a1b6d155a1 | |||
| 0876a1d568 | |||
| f3e274b33d | |||
| 0a1e558942 | |||
| baffaa46c9 | |||
| 28e46771b1 | |||
| 8df850f73e | |||
| 7e1f037aad | |||
| 22fd8c5da7 |
40
README.md
40
README.md
@@ -36,17 +36,51 @@ rows here; the website core never learns there is more than one.
|
||||
| Surface | Route |
|
||||
|---|---|
|
||||
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the same, on the authenticated tier |
|
||||
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
|
||||
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
|
||||
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
|
||||
| Public | `GET …/servers/:id/wipes` and `…/online` |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
||||
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
|
||||
| Page | `/rust/servers` |
|
||||
| Pages | `/rust` — the server list, and the module's landing page |
|
||||
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
|
||||
| Slot | `site.footer.status` — a live server/player count in core's footer |
|
||||
|
||||
Two tables, `rust_servers` (configuration) and `rust_server_state` (what each sidecar reported).
|
||||
Every page reads this module's own tables and never calls a game server, which is what lets the
|
||||
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
|
||||
sort all live in the URL, so any view of it is a link.
|
||||
|
||||
Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed
|
||||
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
|
||||
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
|
||||
|
||||
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
|
||||
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
|
||||
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
|
||||
both surfaces an operator can configure and then wait on, which is worse than an absent one.
|
||||
|
||||
### What a client feature-detects on
|
||||
|
||||
`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any
|
||||
client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10).
|
||||
Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`.
|
||||
|
||||
The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is
|
||||
not, for two reasons worth writing down before somebody tidies it away:
|
||||
|
||||
- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every
|
||||
started module's capabilities into one list, so `servers` alone is a word another module could
|
||||
declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean
|
||||
this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard`
|
||||
does for `module-uo`.
|
||||
- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the
|
||||
directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must
|
||||
never infer a route from a capability. Gating on `id` would quietly make the two the same thing,
|
||||
and the day a client builds `/<id>/servers` from it, the contract that lets this module move its
|
||||
own pages is gone.
|
||||
|
||||
An unknown capability is absent, and no route is ever derived from one.
|
||||
|
||||
## Build and check
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,12 +29,14 @@
|
||||
"server": [
|
||||
"boot.js",
|
||||
"catalogue.js",
|
||||
"configEdit.js",
|
||||
"core.js",
|
||||
"db",
|
||||
"index.js",
|
||||
"ingest.js",
|
||||
"model",
|
||||
"package.json",
|
||||
"permSync.js",
|
||||
"router",
|
||||
"sidecarClient.js"
|
||||
],
|
||||
|
||||
@@ -25,6 +25,45 @@ const { request: req, BASE } = rg.api
|
||||
// registers under the `/rust` prefix `module.json` declares.
|
||||
export const servers = {
|
||||
list: () => req('/public/rust/servers'),
|
||||
|
||||
// One server, and the only route under `/servers/:id` that can answer "no such
|
||||
// server": the four below answer an empty list for an id nobody ever
|
||||
// configured, because an unknown server genuinely has no events.
|
||||
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
|
||||
|
||||
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
|
||||
// both are built here rather than in a page, so the query string this module
|
||||
// sends exists in one file.
|
||||
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
|
||||
kind: kinds && kinds.length ? kinds.join(',') : null,
|
||||
wipe,
|
||||
limit,
|
||||
})}`),
|
||||
|
||||
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
|
||||
|
||||
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
|
||||
|
||||
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
|
||||
}
|
||||
|
||||
/**
|
||||
* A query string from the parameters that have a value, or `''`.
|
||||
*
|
||||
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
|
||||
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
|
||||
* string — and a page that sends one because a `<select>` is on "All time" gets
|
||||
* an empty leaderboard and no error.
|
||||
*/
|
||||
function query(params) {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
|
||||
}
|
||||
const string = search.toString()
|
||||
return string ? `?${string}` : ''
|
||||
}
|
||||
|
||||
// ── player ────────────────────────────────────────────────────────────────
|
||||
@@ -35,6 +74,20 @@ export const playerServers = {
|
||||
list: () => req('/player/rust/servers'),
|
||||
}
|
||||
|
||||
// R1's identity link, from the signed-in player's side.
|
||||
//
|
||||
// **The code is the whole of what goes up.** The site has no idea which server
|
||||
// minted it — nothing in six characters says — so the server half asks each
|
||||
// configured server in turn (D24). A page that asked the player to pick would be
|
||||
// asking them a question the site can answer itself, and a wrong pick would come
|
||||
// back indistinguishable from a wrong code.
|
||||
export const playerLinks = {
|
||||
list: () => req('/player/rust/links'),
|
||||
confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }),
|
||||
remove: (steamId) =>
|
||||
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// ── admin ─────────────────────────────────────────────────────────────────
|
||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
||||
// and a save that omits the field leaves the stored credential alone — so an
|
||||
@@ -50,8 +103,122 @@ export const admin = {
|
||||
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
// ── admin · permissions (R2) ──────────────────────────────────────────────
|
||||
//
|
||||
// The authoring surface. Every call here writes to the SITE, and none of them
|
||||
// reaches a game server — the mirror's own loop does that on its own cadence.
|
||||
// `sync` is the exception and says so in its name: it runs the pass now and
|
||||
// answers with what each server reported, which is the only call on this screen
|
||||
// that can be slow or fail because a game host is down.
|
||||
//
|
||||
// A write is followed by a re-read rather than a local edit of the model: what
|
||||
// the screen is showing is partly the game's answer, and the honest way to learn
|
||||
// the new one is to ask.
|
||||
export const adminPermissions = {
|
||||
overview: () => req('/admin/rust/permissions'),
|
||||
catalogue: () => req('/admin/rust/permissions/catalogue'),
|
||||
|
||||
saveGroup: (name, body) =>
|
||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
|
||||
deleteGroup: (name) =>
|
||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
|
||||
addMember: (name, username) =>
|
||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
|
||||
method: 'POST',
|
||||
body: { username },
|
||||
}),
|
||||
removeMember: (name, userId) =>
|
||||
req(
|
||||
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
|
||||
revoke: (id) =>
|
||||
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
adoptDrift: (id) =>
|
||||
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
|
||||
revokeDrift: (id) =>
|
||||
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
|
||||
|
||||
sync: (serverId = null) =>
|
||||
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
|
||||
}
|
||||
|
||||
// ── admin · mod configuration (R18) ───────────────────────────────────────
|
||||
//
|
||||
// Every call here is a LIVE round trip to a game host, which makes this the only
|
||||
// section of this file where a call can be slow, or fail because a server is
|
||||
// off. Nothing is cached anywhere between the browser and the host's disk: a
|
||||
// cached config is an edit an operator made over SSH that this website then
|
||||
// silently overwrote.
|
||||
//
|
||||
// `save` carries a `version` the host issued with the file. Send a stale one and
|
||||
// the answer is a 409 with the current file attached, rather than an overwrite
|
||||
// of whatever somebody else changed in the meantime.
|
||||
export const adminConfig = {
|
||||
files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`),
|
||||
|
||||
file: (serverId, path) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`),
|
||||
|
||||
// Two tiers, one route. `edits` is the generated form — pointers and literals,
|
||||
// type-preserving — and `text` is the raw document. A number travels as TEXT
|
||||
// in both: `1.0` parsed into a JavaScript number and sent back as `1` is the
|
||||
// whole failure this feature was designed around.
|
||||
save: (serverId, body) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }),
|
||||
|
||||
writes: (serverId, limit = null) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
|
||||
}
|
||||
|
||||
// ── the admin.users.detail extension slot ─────────────────────────────────
|
||||
//
|
||||
// The client half of R13's first slot. Core hands the component a `userId` and
|
||||
// NOTHING else — not a client — so an extension builds its own bindings for the
|
||||
// routes it registered at the other end (§3.5). These two are the only calls in
|
||||
// this file whose path is core's rather than this module's: the resource is
|
||||
// core's user, and the module's own segment is the part after it.
|
||||
export const adminUserLinks = {
|
||||
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`),
|
||||
remove: (userId, steamId) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}
|
||||
|
||||
// The same panel's phase 7 half: what this person may do in game. The id in the
|
||||
// path is the one the slot handed the component, so these send `userId` rather
|
||||
// than a name — the screen already knows who it is looking at.
|
||||
export const adminUserPermissions = {
|
||||
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`),
|
||||
grant: (userId, body) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
revoke: (userId, grantId) =>
|
||||
req(
|
||||
`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
}
|
||||
|
||||
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||
// download link, an EventSource. Reach for `request` first.
|
||||
export { BASE }
|
||||
export { BASE, query }
|
||||
|
||||
export default { servers, playerServers, admin, BASE }
|
||||
export default {
|
||||
servers,
|
||||
playerServers,
|
||||
playerLinks,
|
||||
admin,
|
||||
adminPermissions,
|
||||
adminConfig,
|
||||
adminUserLinks,
|
||||
adminUserPermissions,
|
||||
BASE,
|
||||
}
|
||||
|
||||
141
client/src/components/Feed.jsx
Normal file
141
client/src/components/Feed.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// ── The feed: what happened on one server ─────────────────────────────────
|
||||
//
|
||||
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
|
||||
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
|
||||
// player's report about another player, or the grid square somebody's base is in
|
||||
// is stored and never answered here — so this component cannot leak one by
|
||||
// forgetting to filter, which is the point of the boundary living on the server.
|
||||
//
|
||||
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
|
||||
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
|
||||
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||
// this job.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||
import { ago, clock } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
|
||||
const TONE = {
|
||||
kill: 'var(--accent-bright)',
|
||||
death: 'var(--muted)',
|
||||
join: 'var(--mode-live, #5fb98a)',
|
||||
leave: 'var(--dim)',
|
||||
chat: 'var(--text)',
|
||||
server: 'var(--mode-maint, #e6c26a)',
|
||||
other: 'var(--muted)',
|
||||
}
|
||||
|
||||
export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
const kinds = kindsFor(filter)
|
||||
|
||||
const { data, error, loading, at } = 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
|
||||
// does not, because it is the same question asked again.
|
||||
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
|
||||
)
|
||||
|
||||
const events = data ? data.events : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
|
||||
>
|
||||
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Showing{' '}
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => onFilter(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
<option key={f.id} value={f.id}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* What a refresh is FOR: saying when the page last managed one. Without
|
||||
it a feed that stopped updating looks exactly like a quiet server. */}
|
||||
{at && (
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
|
||||
the last refresh failed — showing what we had
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
|
||||
{/* An error with nothing to fall back on is the only case that takes over
|
||||
the panel. A failed REFRESH keeps the rows and says so in the line
|
||||
above, because a site whose premise is "it renders while the game is
|
||||
off" must not blank itself the first time a request does. */}
|
||||
{error && !data && <ErrorState error={error} />}
|
||||
|
||||
{data && events.length === 0 && (
|
||||
<EmptyState
|
||||
title="Nothing here yet"
|
||||
message="Nothing this server has reported matches. A server that has just been added has no history until it says something."
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{events.map((event) => {
|
||||
const line = describe(event)
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<time
|
||||
className="sans"
|
||||
dateTime={new Date(event.t).toISOString()}
|
||||
title={new Date(event.t).toLocaleString()}
|
||||
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
|
||||
>
|
||||
{clock(event.t)}
|
||||
</time>
|
||||
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
|
||||
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
|
||||
{line.actor && (line.join || ' ')}
|
||||
{line.verb}
|
||||
{line.subject && ' '}
|
||||
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
|
||||
{line.detail && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
{' · '}
|
||||
{line.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.78rem',
|
||||
}
|
||||
73
client/src/components/FooterStatus.jsx
Normal file
73
client/src/components/FooterStatus.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// ── This module's fill for core's `site.footer.status` slot ───────────────
|
||||
//
|
||||
// R13, and the contract is MODULE_API.md §3.7. Core owns the position in the
|
||||
// footer's info row and the separator around it, and passes `linkStyle` so the
|
||||
// row stays visually one row. **The label, the destination, the data and whether
|
||||
// anything renders at all are this component's** — that is the whole division,
|
||||
// and it is why the slot is named for a place rather than for a meaning.
|
||||
//
|
||||
// ── The live count, and what it costs ─────────────────────────────────────
|
||||
//
|
||||
// The org lead chose a live count ("3 servers · 42 online") over a static link,
|
||||
// so this fetches. Be clear-eyed about where it fetches from: core renders
|
||||
// `SiteFooter` inside `PublicLayout`, and every public page renders
|
||||
// `PublicLayout` ITSELF (§3.3) — so this component mounts once per public page
|
||||
// view, not once per session. Every public page on the site therefore carries one
|
||||
// `/public/rust/servers` request, including pages that have nothing to do with
|
||||
// Rust.
|
||||
//
|
||||
// Two things keep that honest rather than merely cheap:
|
||||
//
|
||||
// • **It renders NOTHING until it has an answer, and nothing again if the
|
||||
// request fails.** An unfilled slot renders nothing and core's `wrap` takes
|
||||
// the separator with it, so a failed fetch degrades to exactly the footer an
|
||||
// instance with no module installed has. A spinner in a footer would be worse
|
||||
// than silence on every page of the site.
|
||||
// • **It never polls.** One request per page view is a cost; a timer in the
|
||||
// footer of every page would be a different kind of thing entirely.
|
||||
//
|
||||
// If that per-page request ever shows up in an operator's logs as a problem, the
|
||||
// fix is a short-lived module-scope cache here — the decision to keep the number
|
||||
// live stays intact, and nothing else on the site has to change.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function FooterStatus({ linkStyle }) {
|
||||
const [summary, setSummary] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
|
||||
api.servers
|
||||
.list()
|
||||
.then(({ servers }) => {
|
||||
if (!live) return
|
||||
// `online` already accounts for staleness — the model refuses to let a
|
||||
// row that has not been written in five minutes claim a server is up —
|
||||
// so this is a sum, not a judgement.
|
||||
setSummary({
|
||||
servers: servers.length,
|
||||
players: servers.reduce((total, server) => total + (server.online ? server.players : 0), 0),
|
||||
})
|
||||
})
|
||||
// Silence, deliberately. This is the footer of every page on the site; a
|
||||
// module that cannot reach its own API has nothing to say there.
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!summary || summary.servers === 0) return null
|
||||
|
||||
return (
|
||||
<Link to="/rust" style={linkStyle}>
|
||||
{summary.servers === 1 ? '1 server' : `${summary.servers} servers`}
|
||||
{' · '}
|
||||
{summary.players === 1 ? '1 online' : `${summary.players} online`}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
110
client/src/components/Leaderboard.jsx
Normal file
110
client/src/components/Leaderboard.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
// ── The leaderboard ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Per wipe when a wipe is selected, all-time when it is not (R12). The two are
|
||||
// the same rows summed differently rather than two sets of counters, so they can
|
||||
// never disagree — which is worth knowing here because it means "All time" is
|
||||
// not a slower or less accurate answer, it is the same table without a WHERE.
|
||||
//
|
||||
// It does NOT poll. A leaderboard moves on the scale of a session; a table that
|
||||
// re-sorted itself under the reader's cursor every twenty seconds would be worse
|
||||
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
||||
// strip for anybody who disagrees.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ago, count, duration, shortId } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
|
||||
// and the column it maps to is this file's. Keeping them in one list is what
|
||||
// stops a header that sorts by something other than what it says.
|
||||
const COLUMNS = [
|
||||
{ key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) },
|
||||
{ key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) },
|
||||
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
|
||||
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
|
||||
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
|
||||
]
|
||||
|
||||
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
const { data, loading, error } = useAsync(
|
||||
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
|
||||
[serverId, wipeId, sort],
|
||||
)
|
||||
|
||||
const rows = data ? data.leaderboard : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No scores yet"
|
||||
message={
|
||||
wipeId
|
||||
? 'Nobody has done anything countable on this wipe yet.'
|
||||
: 'This server has not reported anything countable yet.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
|
||||
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
|
||||
{COLUMNS.map((column) => (
|
||||
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
|
||||
{column.sort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column.sort)}
|
||||
aria-label={`Sort by ${column.label}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<td style={cell}>
|
||||
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
|
||||
{/* A player this module has never seen NAMED is shown by the tail
|
||||
of their id rather than as a blank: the row is real, and a
|
||||
nameless one reads as a rendering fault. */}
|
||||
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
||||
</td>
|
||||
{COLUMNS.map((column) => (
|
||||
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
||||
{column.value(row)}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }
|
||||
94
client/src/components/Online.jsx
Normal file
94
client/src/components/Online.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// ── Who is on the server right now ────────────────────────────────────────
|
||||
//
|
||||
// Read from the presence BOARD, not counted from connect and disconnect events:
|
||||
// the bridge re-sends the whole board on every connect and every sixty seconds,
|
||||
// so this is right even after the website has missed something (PROTOCOL.md
|
||||
// §8.3). Counting transitions instead would drift, and drift in the direction
|
||||
// people notice — players who never left.
|
||||
//
|
||||
// It polls with the feed, because "who is on" is the one thing on this page that
|
||||
// is a live question.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { duration, shortId } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function Online({ serverId, online }) {
|
||||
const { data, error, loading } = usePolled(() => api.servers.online(serverId), {
|
||||
key: serverId,
|
||||
intervalMs: 20_000,
|
||||
})
|
||||
|
||||
const players = data ? data.players : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} />
|
||||
|
||||
if (players.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||
message={
|
||||
online
|
||||
? 'The server is up and the island is empty. Somebody has to be first.'
|
||||
: 'Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* A board is the last one that ARRIVED, and an unreachable sidecar does not
|
||||
clear it — deliberately, because the rows are still the best answer
|
||||
anybody has. But presented bare they read as "these people are on right
|
||||
now", which is the one thing an offline server cannot be saying. The
|
||||
page walk found this with a fixture server whose header said Offline
|
||||
above three apparently-connected players. */}
|
||||
{!online && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
This server is offline. Below is the last board it sent, not who is on it now.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{players.map((player) => (
|
||||
<li
|
||||
key={player.steamId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{player.name || shortId(player.steamId)}</strong>
|
||||
{/* Sleeping is not idle and not offline — a sleeping player's body is
|
||||
in the world and can be killed, which is why the board carries the
|
||||
flag at all. */}
|
||||
{player.sleeping && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}> · sleeping</span>
|
||||
)}
|
||||
</span>
|
||||
{/* `connectedAt` is absent for a player who was already on when the
|
||||
plugin loaded — an unknown session length, which is not a session of
|
||||
no length. Saying nothing is the honest render of that. */}
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
{player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** How long a player has been on, from the DATETIME the board reported. */
|
||||
function sessionSoFar(connectedAt) {
|
||||
const since = Date.parse(connectedAt)
|
||||
if (Number.isNaN(since)) return ''
|
||||
return duration((Date.now() - since) / 1000)
|
||||
}
|
||||
63
client/src/components/Tabs.jsx
Normal file
63
client/src/components/Tabs.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// ── Tabs, bundled rather than borrowed ────────────────────────────────────
|
||||
//
|
||||
// The shared kit is nine members and it is CLOSED (MODULE_API.md §3.4): layout,
|
||||
// headings, the three data-page states, the fetch hook, the session, the site
|
||||
// and `Slot`. A tab strip is not in it, so it is here — which is the kit working
|
||||
// as designed rather than a gap in it. What the kit guarantees is that a module
|
||||
// page looks like the site while it loads and while it fails; everything a page
|
||||
// builds on top of that is the module's own.
|
||||
//
|
||||
// It is styled with core's CSS VARIABLES and its `.pill` class rather than with
|
||||
// colours of its own, so it re-themes with the instance (THEMING_AND_NAV.md).
|
||||
// The one class this module must never write by hand is the shell wrapper —
|
||||
// `PublicLayout`'s `shell` prop exists precisely so that one stays core's.
|
||||
//
|
||||
// **The selected tab lives in the URL, not in this component.** A tab strip that
|
||||
// owned its own state would make every panel on this page unlinkable: "look at
|
||||
// the leaderboard for this server" would be a sentence rather than a link, back
|
||||
// would leave the page entirely, and a refresh would land on the first tab. So
|
||||
// this is a controlled component and `ServerDetail` keeps the state in a search
|
||||
// parameter.
|
||||
|
||||
export default function Tabs({ tabs, active, onSelect, label = 'Sections' }) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
borderBottom: '1px solid var(--line)',
|
||||
paddingBottom: 12,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === active
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 'var(--radius-pill, 999px)',
|
||||
fontSize: '0.82rem',
|
||||
letterSpacing: '0.04em',
|
||||
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: selected ? 'var(--blue)' : 'transparent',
|
||||
color: selected ? 'var(--accent-bright)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
client/src/components/WipeSelect.jsx
Normal file
54
client/src/components/WipeSelect.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// ── "This wipe" or "All time" ─────────────────────────────────────────────
|
||||
//
|
||||
// One control, used by two panels, because the wipe is a property of the PAGE
|
||||
// rather than of the feed or the leaderboard — a reader who has chosen last
|
||||
// month's map means it for both, and two selects that could disagree is a page
|
||||
// that shows one wipe's kills next to another's leaderboard.
|
||||
//
|
||||
// It loads the wipe list itself. That is a second request for the same list the
|
||||
// Wipes tab fetches, and it is the right trade: the alternative is the page
|
||||
// fetching it on mount for a control most visitors never touch, on every visit,
|
||||
// for every server.
|
||||
|
||||
import { useAsync } from '../core.js'
|
||||
import { day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
/** The value that means "no wipe filter at all". Never the empty string — see `api.js`'s `query`. */
|
||||
export const ALL_TIME = 'all'
|
||||
|
||||
export default function WipeSelect({ serverId, value, onChange, currentWipeId }) {
|
||||
const { data } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||
const wipes = data ? data.wipes : []
|
||||
|
||||
// A server with one wipe has nothing to choose between, so the control is not
|
||||
// offered. "All time" and "this wipe" are the same answer there, and a select
|
||||
// with one real option is furniture that invites a question with no answer.
|
||||
if (wipes.length < 2) return null
|
||||
|
||||
return (
|
||||
<label className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Wipe{' '}
|
||||
<select
|
||||
value={value || ALL_TIME}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
style={{
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
<option value={ALL_TIME}>All time</option>
|
||||
{wipes.map((wipe) => (
|
||||
<option key={wipe.wipeId} value={wipe.wipeId}>
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
{wipe.wipeId === currentWipeId ? ' (current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
85
client/src/components/Wipes.jsx
Normal file
85
client/src/components/Wipes.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// ── Every wipe this server has had ────────────────────────────────────────
|
||||
//
|
||||
// The list is what makes the rest of the page navigable — picking a wipe here
|
||||
// filters the feed and the leaderboard — and it is also the proof R12 asks for:
|
||||
// a wipe that ended is still here, with its record still attached. A Rust server
|
||||
// wipes monthly, and a community site that forgot the previous map every time
|
||||
// would throw away most of what it knows about its own players.
|
||||
//
|
||||
// `wipeId` is derived by the bridge PLUGIN from the save's creation time and
|
||||
// stamped on every frame (PROTOCOL.md §8.2), so the id in this list is the same
|
||||
// id the events and the leaderboard filter by. There is no second derivation
|
||||
// anywhere that could disagree.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ago, day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
|
||||
const { data, loading, error } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||
const wipes = data ? data.wipes : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (wipes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No wipes recorded"
|
||||
message="A wipe appears here once this server has reported something during it."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{wipes.map((wipe) => {
|
||||
const current = wipe.wipeId === currentWipeId
|
||||
const active = wipe.wipeId === selected
|
||||
return (
|
||||
<li key={wipe.wipeId} style={{ borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(wipe.wipeId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 6px',
|
||||
cursor: 'pointer',
|
||||
background: active ? 'var(--blue)' : 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>
|
||||
{/* The save's creation time is the wipe's own date; `firstSeen`
|
||||
is when THIS website first heard about it, and they differ
|
||||
by however long the module was not installed. The first is
|
||||
the wipe, so it leads. */}
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
</strong>
|
||||
{current && (
|
||||
<span className="sans" style={{ color: 'var(--mode-live, #5fb98a)', fontSize: '0.74rem' }}>
|
||||
{' · current'}
|
||||
</span>
|
||||
)}
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||
{wipe.wipeId}
|
||||
</span>
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
last heard {ago(wipe.lastSeen)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,13 @@
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
|
||||
import Servers from './routes/public/Servers.jsx'
|
||||
import ServerDetail from './routes/public/ServerDetail.jsx'
|
||||
import Account from './routes/player/Account.jsx'
|
||||
import Permissions from './routes/admin/Permissions.jsx'
|
||||
import ModConfig from './routes/admin/ModConfig.jsx'
|
||||
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||
import FooterStatus from './components/FooterStatus.jsx'
|
||||
import { IconKey, IconLink, IconSliders } from './icons.jsx'
|
||||
|
||||
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||
// and prefixes every route path with it.
|
||||
@@ -33,7 +40,7 @@ const ID = 'rust'
|
||||
// installed side by side cannot collide, and an operator can see from a URL which
|
||||
// module served it.
|
||||
//
|
||||
// So this page is at `/rust/servers`.
|
||||
// So the list below is at `/rust` and the detail page at `/rust/servers/:id`.
|
||||
//
|
||||
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
||||
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||
@@ -41,11 +48,53 @@ const ID = 'rust'
|
||||
// see what, and they only do if one thing decides.
|
||||
//
|
||||
// R8's landing page is the server list, and `/rust/servers/:id` hangs beneath it.
|
||||
// The detail route is a later phase's, and it is deliberately not stubbed here: a
|
||||
// registered route that renders nothing is a 200 with a blank page, which is
|
||||
// worse than the 404 an unregistered one gives.
|
||||
//
|
||||
// **The list is registered with an EMPTY path**, which core renders as the
|
||||
// module's namespace root: `/rust`. The prefixing code strips the separator it
|
||||
// would otherwise leave behind (`registry.js`: `${id}/${path}` with trailing
|
||||
// slashes trimmed), so a module can own its own root without being able to spell
|
||||
// its way out of it. Phase 1 served this page at `/rust/servers` and left `/rust`
|
||||
// to core's CMS catch-all; the org lead settled it at `/rust` in phase 4, so the
|
||||
// address an operator links to is the module's name.
|
||||
//
|
||||
// React Router ranks a static segment above a dynamic one, so `/rust` wins
|
||||
// against core's `/:slug` CMS route without depending on registration order.
|
||||
//
|
||||
// The player route is registered with an empty path for the same reason the
|
||||
// public list is: `/player/rust` is the whole of what this module asks a player
|
||||
// to do, and a landing page above one page is a page nobody wants. Core applies
|
||||
// its own portal chrome and its own auth gate to the tier, so the component
|
||||
// renders no layout and re-implements no check.
|
||||
//
|
||||
// **The admin route arrives in phase 7 and is this module's first.** Everything
|
||||
// before it was configured through the API — the server rows still are — because
|
||||
// nothing until now had to be AUTHORED. A permission model is different in kind:
|
||||
// it is a thing an operator composes and keeps looking at, and there is no
|
||||
// version of "grant somebody VIP" that belongs in a terminal.
|
||||
//
|
||||
// It is registered with an empty path, so it lands at `/admin/rust`, and core
|
||||
// applies the admin tier's own gate. The routes underneath it are stricter than
|
||||
// that gate (`requireRole('admin')` on every one), which is a server-side answer
|
||||
// rather than a client one: a moderator who reached this page would see it fail
|
||||
// honestly rather than be quietly shown a page that cannot save.
|
||||
registry.registerRoutes(ID, {
|
||||
public: [{ path: 'servers', element: <Servers /> }],
|
||||
public: [
|
||||
{ path: '', element: <Servers /> },
|
||||
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||
],
|
||||
player: [{ path: '', element: <Account /> }],
|
||||
admin: [
|
||||
{ path: '', element: <Permissions /> },
|
||||
// Phase 7b (R18). A second admin page rather than a tab on the first: the
|
||||
// permission mirror decides who may do what inside the game, and this edits
|
||||
// the game host's own files. They are neighbours, not halves of one screen,
|
||||
// and the nav says so with two rows.
|
||||
//
|
||||
// A static segment under the module's namespace, so it lands at
|
||||
// `/admin/rust/config` and core's admin gate applies to it exactly as it
|
||||
// does to the page above.
|
||||
{ path: 'config', element: <ModConfig /> },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||
@@ -67,9 +116,59 @@ registry.registerRoutes(ID, {
|
||||
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||
registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [{ label: 'Servers', to: '/rust/servers' }],
|
||||
items: [{ label: 'Servers', to: '/rust' }],
|
||||
})
|
||||
|
||||
// The player portal's row. It carries an `icon` because core draws one on every
|
||||
// portal row — a row without one is the only text in a column of glyphs, and
|
||||
// core used to render `<n.icon />` unguarded, which blanked the whole portal.
|
||||
//
|
||||
// No `order`: an unordered row appends after core's own rather than claiming a
|
||||
// position it was not given. Account, appeals and notifications are what a player
|
||||
// came to the portal for; linking a game account is what they do once.
|
||||
registry.registerNav(ID, {
|
||||
area: 'player',
|
||||
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
|
||||
})
|
||||
|
||||
// The admin sidebar's row. `group` names an existing core group — an unknown name
|
||||
// appends a new group at the end rather than dropping the row, which is the
|
||||
// failure mode to avoid here: a row nobody can find is a feature nobody has.
|
||||
//
|
||||
// It carries an icon for the same reason the player row does: core draws one on
|
||||
// every sidebar row, and the one without is the only text in a column of glyphs.
|
||||
registry.registerNav(ID, {
|
||||
area: 'admin',
|
||||
items: [
|
||||
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
|
||||
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Extension slots ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core declares a slot, only core may declare one, and at most one module may
|
||||
// fill it (§3.7). `site.footer.status` is the status-ish spot in core's footer
|
||||
// info row: core owns the position and passes `linkStyle`; the label, the
|
||||
// destination, the data and whether anything renders at all are the module's.
|
||||
//
|
||||
// It is a CLIENT slot and cannot be named in `module.json`'s `extensions` —
|
||||
// that array is validated against the SERVER registry, and naming a client slot
|
||||
// there fails the load outright with `unknown extension slot`. Phase 1 found
|
||||
// that the hard way; the two halves of R13 are declared in different places on
|
||||
// purpose.
|
||||
registry.registerExtension(ID, 'site.footer.status', FooterStatus)
|
||||
|
||||
// R13's other slot, and the one that IS named in `module.json` — because it has
|
||||
// a server half too (`server/router/admin/usersRust.router.js`). The two halves
|
||||
// carry one name on purpose: a module that adds routes under
|
||||
// `/api/v1/admin/users/:id` is the module with something to show on that page.
|
||||
//
|
||||
// Core passes `userId` and nothing else, so the component builds its own client
|
||||
// for the routes the server half registered. It renders NOTHING for a user with
|
||||
// no linked Steam account, which is most of them.
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
|
||||
|
||||
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
||||
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
||||
// between the core that validated the manifest and the core that published this
|
||||
|
||||
116
client/src/hooks/usePolled.js
Normal file
116
client/src/hooks/usePolled.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── A poll that keeps what it already had ─────────────────────────────────
|
||||
//
|
||||
// **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and
|
||||
// `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})`
|
||||
// re-run on a dependency change — and the first thing it does on every run is
|
||||
// blank `data` and set `loading`. That is right for a page load and wrong for a
|
||||
// poll: bumping a dependency every twenty seconds would clear the killfeed,
|
||||
// render `<Loading />` in its place and re-fill it, four times a minute, for ever.
|
||||
//
|
||||
// So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps
|
||||
// the previous rows on screen, replaces them when the new ones arrive, and keeps
|
||||
// them *and* reports the error when the fetch fails — because a site whose whole
|
||||
// premise is "it renders while the game is off" must not blank the page the
|
||||
// first time a request does.
|
||||
//
|
||||
// `useAsync` is still the right hook for everything that loads once, and the
|
||||
// pages here use it for exactly that. Bundling this beside it is the kit working
|
||||
// as intended: the nine shared members are the chrome every module must share,
|
||||
// not a ceiling on what a module may write.
|
||||
//
|
||||
// ── Two behaviours worth knowing ──────────────────────────────────────────
|
||||
//
|
||||
// 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate
|
||||
// refresh when the viewer comes back — which is also the moment stale rows
|
||||
// are most visible. A tab left open overnight is otherwise a request every
|
||||
// twenty seconds until the laptop dies.
|
||||
// 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD
|
||||
// blank the rows: what is on screen belongs to a different question. That is
|
||||
// what `key` is for, and it is separate from the interval.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* @param {() => Promise<any>} fetcher called with no arguments; must not throw synchronously
|
||||
* @param {object} options
|
||||
* @param {string} options.key changes when the QUESTION changes, blanking the answer
|
||||
* @param {number} options.intervalMs 0 disables polling — the hook then loads once
|
||||
* @param {boolean} options.enabled false while the page has nothing to ask about yet
|
||||
*/
|
||||
export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) {
|
||||
const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null })
|
||||
|
||||
// The fetcher is rebuilt on every render — it closes over props — and a hook
|
||||
// that listed it as a dependency would restart its interval every render. The
|
||||
// ref is how the timer keeps calling the CURRENT one without depending on it.
|
||||
const latest = useRef(fetcher)
|
||||
latest.current = fetcher
|
||||
|
||||
// Guards a reply from a question nobody is asking any more: a slow request
|
||||
// whose page has moved on, or one still in flight at unmount.
|
||||
const generation = useRef(0)
|
||||
|
||||
const run = useCallback(
|
||||
async (mine) => {
|
||||
try {
|
||||
const data = await latest.current()
|
||||
if (mine !== generation.current) return
|
||||
setState({ data, error: null, loading: false, at: Date.now() })
|
||||
} catch (error) {
|
||||
if (mine !== generation.current) return
|
||||
// `data` is carried forward deliberately. A failed refresh is a page that
|
||||
// says "this is what we last knew, and it did not refresh", which is the
|
||||
// same promise the server list makes about a game server being down.
|
||||
setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at }))
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const refresh = useCallback(() => run(generation.current), [run])
|
||||
|
||||
useEffect(() => {
|
||||
generation.current += 1
|
||||
const mine = generation.current
|
||||
|
||||
if (!enabled) {
|
||||
setState({ data: null, error: null, loading: false, at: null })
|
||||
return undefined
|
||||
}
|
||||
|
||||
setState({ data: null, error: null, loading: true, at: null })
|
||||
run(mine)
|
||||
|
||||
if (!intervalMs) return () => { generation.current += 1 }
|
||||
|
||||
let timer = null
|
||||
|
||||
const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
|
||||
const start = () => {
|
||||
if (timer === null) timer = setInterval(() => run(mine), intervalMs)
|
||||
}
|
||||
const stop = () => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
const onVisibility = () => {
|
||||
if (visible()) { run(mine); start() } else stop()
|
||||
}
|
||||
|
||||
if (visible()) start()
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
return () => {
|
||||
// Bumping the generation on teardown is what makes an in-flight reply from
|
||||
// the old question land nowhere. Clearing the timer alone would not.
|
||||
generation.current += 1
|
||||
stop()
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [key, intervalMs, enabled, run])
|
||||
|
||||
return { ...state, refresh }
|
||||
}
|
||||
|
||||
export default usePolled
|
||||
86
client/src/icons.jsx
Normal file
86
client/src/icons.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── The nav glyph for this module's player-portal row ─────────────────────
|
||||
//
|
||||
// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core
|
||||
// renders whatever component a row carries, exactly as it renders its own rows'
|
||||
// icons — and core's player portal draws a glyph on every row, so a row without
|
||||
// one reads as breakage rather than as a design. The client suite asserts it.
|
||||
//
|
||||
// The public header is text buttons and carries no icons, which is why this file
|
||||
// arrives with the player row and not before it.
|
||||
//
|
||||
// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by
|
||||
// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes
|
||||
// of presentation are not a component: putting them in the shared kit would
|
||||
// freeze core's icon sizing into the contract, where changing it later would be a
|
||||
// major bump. A module that wants to look like the nav it is in matches that nav.
|
||||
|
||||
const Icon = ({ children }) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* A chain link — what the row is for.
|
||||
*
|
||||
* Not a gem, a person or a server: the portal's rows say what a player does
|
||||
* there, and what a player does at `/player/rust` is link an account. Core's own
|
||||
* neighbours are a gear (account), a shield (appeals) and a bell (notifications),
|
||||
* so the row has to read as a verb in that company.
|
||||
*/
|
||||
export const IconLink = () => (
|
||||
<Icon>
|
||||
<path d="M10 13a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11.5 4.5" />
|
||||
<path d="M14 11a5 5 0 00-7.07 0L4.1 13.83a5 5 0 007.07 7.07L12.5 19.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* A key — the admin sidebar's row for the permission mirror.
|
||||
*
|
||||
* Core's admin groups are labelled by subject and drawn with glyphs of the same
|
||||
* weight, so this is the same 16px frame as the portal's. A key rather than a
|
||||
* shield: a shield is protection from something, and this row is about handing
|
||||
* somebody the right to do something.
|
||||
*/
|
||||
export const IconKey = () => (
|
||||
<Icon>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.7 12.3L20 3" />
|
||||
<path d="M17 6l2.5 2.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* Sliders — the admin sidebar's row for the mod-configuration editor.
|
||||
*
|
||||
* Not a gear: core's account row is a gear, and two gears in one sidebar say
|
||||
* "settings" twice without saying whose. Sliders read as values being tuned,
|
||||
* which is exactly what that page does to somebody else's game host.
|
||||
*/
|
||||
export const IconSliders = () => (
|
||||
<Icon>
|
||||
<path d="M4 6h10" />
|
||||
<path d="M18 6h2" />
|
||||
<circle cx="16" cy="6" r="2" />
|
||||
<path d="M4 12h4" />
|
||||
<path d="M12 12h8" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="M4 18h10" />
|
||||
<path d="M18 18h2" />
|
||||
<circle cx="16" cy="18" r="2" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders }
|
||||
178
client/src/lib/feed.js
Normal file
178
client/src/lib/feed.js
Normal file
@@ -0,0 +1,178 @@
|
||||
// ── One stored frame as one line of a feed ────────────────────────────────
|
||||
//
|
||||
// `GET /public/rust/servers/:id/events` answers rows shaped
|
||||
// `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame
|
||||
// the plugin emitted — this module stores what it is given and indexes only the
|
||||
// columns it serves (PROTOCOL.md §8.4, and the `raw` column in schema.sql). So
|
||||
// everything a killfeed line needs is in `frame`, under the names the plugin
|
||||
// wrote, and this file is the one place that knows them.
|
||||
//
|
||||
// **It returns PARTS, not a sentence.** A component wants the names emphasised
|
||||
// and the detail muted, and a function returning `"Alice killed Bob"` forces
|
||||
// either a `dangerouslySetInnerHTML` or a re-parse. Parts also make this
|
||||
// testable without a DOM, which is the whole reason it is not a component.
|
||||
//
|
||||
// ── The rule for an unknown kind ──────────────────────────────────────────
|
||||
//
|
||||
// It renders as itself. A later protocol adds kinds, an operator's module may be
|
||||
// older than their game host, and a feed that DROPPED what it did not recognise
|
||||
// would be a page that quietly says less than the truth. The server's allowlist
|
||||
// has already decided this row may be seen (`server/catalogue.js`); what is left
|
||||
// here is presentation, and the honest presentation of a kind we have no words
|
||||
// for is its own name.
|
||||
|
||||
import { duration, prefab } from './format.js'
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
*
|
||||
* `player.tally` is public and deliberately NOT here: it is an aggregate the
|
||||
* plugin flushes every sixty seconds per active player (§8.6), so a feed
|
||||
* including it would be mostly wood counts. It is the leaderboard's input, and
|
||||
* the leaderboard is where it shows up.
|
||||
*/
|
||||
export const FEED_KINDS = Object.freeze([
|
||||
'player.death',
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.chat',
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
])
|
||||
|
||||
/** The filters the feed offers, and the kinds each one asks the API for. */
|
||||
export const FILTERS = Object.freeze([
|
||||
{ id: 'all', label: 'Everything', kinds: FEED_KINDS },
|
||||
{ id: 'kills', label: 'Kills', kinds: ['player.death'] },
|
||||
{ id: 'chat', label: 'Chat', kinds: ['player.chat'] },
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Comings and goings',
|
||||
kinds: ['player.connected', 'player.disconnected', 'player.respawned'],
|
||||
},
|
||||
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
|
||||
])
|
||||
|
||||
export function kindsFor(filterId) {
|
||||
const filter = FILTERS.find((f) => f.id === filterId)
|
||||
return (filter || FILTERS[0]).kinds
|
||||
}
|
||||
|
||||
/**
|
||||
* One row as `{ tone, actor, join, verb, subject, detail }`.
|
||||
*
|
||||
* `actor` and `subject` are names and are emphasised; `verb` and `detail` are
|
||||
* prose. Any of them may be empty. `tone` is the row's category, for the small
|
||||
* colour the component gives it — never for deciding what a row means.
|
||||
*
|
||||
* `join` is what goes between the actor and the verb, and it exists for exactly
|
||||
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||
* writes, and putting the colon in the message would put presentation inside the
|
||||
* text a player typed.
|
||||
*/
|
||||
export function describe(row) {
|
||||
const frame = (row && row.frame) || {}
|
||||
const name = frame.name || null
|
||||
|
||||
switch (row && row.kind) {
|
||||
case 'player.death':
|
||||
return death(frame, name)
|
||||
|
||||
case 'player.connected':
|
||||
return { tone: 'join', actor: name, verb: 'connected', subject: null, detail: '' }
|
||||
|
||||
case 'player.disconnected':
|
||||
return {
|
||||
tone: 'leave',
|
||||
actor: name,
|
||||
verb: 'disconnected',
|
||||
subject: null,
|
||||
// Two optional halves, and the session is the interesting one: the plugin
|
||||
// omits `sessionSec` for a player who was already on when it loaded, so an
|
||||
// absent value means "unknown", never zero (§8.4's note, and OnPlayerDisconnected).
|
||||
detail: [frame.reason || null, frame.sessionSec ? `after ${duration(frame.sessionSec)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}
|
||||
|
||||
case 'player.respawned':
|
||||
return { tone: 'join', actor: name, verb: 'respawned', subject: null, detail: '' }
|
||||
|
||||
case 'player.chat':
|
||||
return {
|
||||
tone: 'chat',
|
||||
actor: name,
|
||||
join: ': ',
|
||||
// The message is the row, so it goes in `verb` where a component renders
|
||||
// it unemphasised — and it is the one field on this wire a player chooses
|
||||
// the bytes of. React escapes it; nothing here may ever stop doing that.
|
||||
verb: frame.message || '',
|
||||
subject: null,
|
||||
detail: frame.channel && frame.channel !== 'Global' ? frame.channel : '',
|
||||
}
|
||||
|
||||
case 'server.wipe':
|
||||
return {
|
||||
tone: 'server',
|
||||
actor: null,
|
||||
verb: 'The map was wiped',
|
||||
subject: null,
|
||||
detail: frame.wipeId ? `new wipe ${frame.wipeId}` : '',
|
||||
}
|
||||
|
||||
case 'server.initialized':
|
||||
return { tone: 'server', actor: null, verb: 'The server came up', subject: null, detail: '' }
|
||||
|
||||
case 'server.shutdown':
|
||||
return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' }
|
||||
|
||||
default:
|
||||
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A death, which is four different sentences.
|
||||
*
|
||||
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||
* that a reader does not have to guess from an absent field, and collapsing any
|
||||
* two of them loses something (see `DescribeAttacker` in the bridge plugin). A
|
||||
* killfeed that reported a fall as a kill by nobody is the failure this avoids.
|
||||
*/
|
||||
function death(frame, name) {
|
||||
const where = [
|
||||
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
|
||||
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||
frame.grid || null,
|
||||
frame.sleeping ? 'while sleeping' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
switch (frame.attackerType) {
|
||||
case 'player':
|
||||
return { tone: 'kill', actor: frame.attackerName || null, verb: 'killed', subject: name, detail: where }
|
||||
|
||||
case 'self':
|
||||
return { tone: 'death', actor: name, verb: 'died by their own hand', subject: null, detail: where }
|
||||
|
||||
case 'npc':
|
||||
return {
|
||||
tone: 'death',
|
||||
actor: prefab(frame.attackerName) || 'Something',
|
||||
verb: 'killed',
|
||||
subject: name,
|
||||
detail: where,
|
||||
}
|
||||
|
||||
// `environment` and anything else: falling, drowning, the world. `HitInfo`
|
||||
// is legitimately null on this path, so an absent attacker type is this case
|
||||
// rather than a missing field to complain about.
|
||||
default:
|
||||
return { tone: 'death', actor: name, verb: 'died', subject: null, detail: where }
|
||||
}
|
||||
}
|
||||
|
||||
export default { describe, FEED_KINDS, FILTERS, kindsFor }
|
||||
136
client/src/lib/format.js
Normal file
136
client/src/lib/format.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// ── Formatting, with no dependencies and no React ─────────────────────────
|
||||
//
|
||||
// Every function here is pure and takes what the API answered, so the suite next
|
||||
// door can ask all of it without a DOM. That is deliberate: the client half's
|
||||
// real failures are timing and resolution (see `test/build.test.js`), which a
|
||||
// DOM-less runner cannot see — so the way to have any test coverage at all on
|
||||
// this side is to keep the parts that CAN be tested free of React.
|
||||
//
|
||||
// `Intl` does the work. It is in every browser core supports, it knows the
|
||||
// viewer's locale and their clock, and it is one fewer thing in a chunk an
|
||||
// operator ships.
|
||||
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
const UNITS = [
|
||||
['year', 31536000],
|
||||
['month', 2592000],
|
||||
['week', 604800],
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60],
|
||||
['second', 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
|
||||
*
|
||||
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
|
||||
* the model produced, and an event's `t` is the millisecond stamp the plugin put
|
||||
* on the frame. Accepting both here is what stops every caller remembering which
|
||||
* is which.
|
||||
*/
|
||||
export function ago(value, now = Date.now()) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return 'never'
|
||||
|
||||
const seconds = Math.round((at - now) / 1000)
|
||||
const magnitude = Math.abs(seconds)
|
||||
|
||||
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
|
||||
// not what anybody means. Say the thing.
|
||||
if (magnitude < 45) return 'just now'
|
||||
|
||||
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamp on a feed row.
|
||||
*
|
||||
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
|
||||
* reads as this afternoon — which the page walk found the moment it looked at the
|
||||
* previous wipe: three events from August, all apparently a few minutes old.
|
||||
*
|
||||
* `now` is a parameter so the boundary is testable rather than a property of the
|
||||
* machine the test runs on.
|
||||
*/
|
||||
export function clock(value, now = Date.now()) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return ''
|
||||
|
||||
const when = new Date(at)
|
||||
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const today = new Date(now)
|
||||
const sameDay =
|
||||
when.getFullYear() === today.getFullYear() &&
|
||||
when.getMonth() === today.getMonth() &&
|
||||
when.getDate() === today.getDate()
|
||||
|
||||
if (sameDay) return time
|
||||
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
|
||||
}
|
||||
|
||||
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||
export function day(value) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return 'unknown'
|
||||
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* A session or a playtime, as `4h 12m`.
|
||||
*
|
||||
* Seconds are dropped above a minute and kept below it, because a two-hour
|
||||
* session reported to the second is noise and a forty-second one reported as
|
||||
* "0m" is wrong.
|
||||
*/
|
||||
export function duration(seconds) {
|
||||
const total = Number(seconds)
|
||||
if (!Number.isFinite(total) || total <= 0) return '—'
|
||||
if (total < 60) return `${Math.round(total)}s`
|
||||
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.round((total % 3600) / 60)
|
||||
|
||||
if (hours === 0) return `${minutes}m`
|
||||
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
/** Thousands separators, in the viewer's locale. */
|
||||
export function count(value) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||
* `rifle.ak` becomes `rifle ak`.
|
||||
*
|
||||
* Deliberately a light touch rather than a lookup table. A table mapping every
|
||||
* Rust prefab to a pretty name is a second copy of the game's item list that
|
||||
* goes stale every wipe, and the short name is what a Rust player reads on their
|
||||
* own server console anyway.
|
||||
*/
|
||||
export function prefab(name) {
|
||||
if (!name) return ''
|
||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||
export function shortId(steamId) {
|
||||
const id = String(steamId || '')
|
||||
return id.length > 10 ? `…${id.slice(-6)}` : id
|
||||
}
|
||||
|
||||
function toMillis(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export default { ago, clock, day, duration, count, prefab, shortId }
|
||||
554
client/src/routes/admin/ModConfig.jsx
Normal file
554
client/src/routes/admin/ModConfig.jsx
Normal file
@@ -0,0 +1,554 @@
|
||||
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
|
||||
//
|
||||
// R18. An admin picks a server, a plugin and a file, changes something, and the
|
||||
// plugin reloads. This module's second admin page, and the first that writes to
|
||||
// somebody's filesystem.
|
||||
//
|
||||
// **What is on the screen is decided by what is dangerous about the action.**
|
||||
// Four things are true here that are not true anywhere else in this module, and
|
||||
// each of them is a piece of the page rather than a line in a doc:
|
||||
//
|
||||
// • a save can take a required plugin DOWN. So the reload target is a
|
||||
// deliberate choice with the folder name as a guess, the result is reported
|
||||
// as its own panel, and a rollback shows the server's own log line.
|
||||
// • the form cannot express everything a config holds. A `null`, an empty
|
||||
// array and anything past the depth limit are marked and sent to the raw
|
||||
// tier rather than half-drawn.
|
||||
// • three keys in the bridge's own config would cut the link carrying the
|
||||
// edit, or split the server's history. They render read-only, with the
|
||||
// reason (D38).
|
||||
// • configs hold API keys and Discord webhooks. Those fields render masked
|
||||
// with a reveal, which is about the shoulder rather than the wire: an admin
|
||||
// can already read the file over SSH (D37), and the audit trail never
|
||||
// records the values either way.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
function Card({ title, subtitle, children, actions }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{actions}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Warn({ children, tone = '#d08a2a' }) {
|
||||
return (
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** A value the form can edit: one row, typed by what the file already holds. */
|
||||
function Field({ field, value, onChange, revealed, onReveal }) {
|
||||
const indent = 12 * Math.max(0, field.depth - 1)
|
||||
const label = (
|
||||
<label
|
||||
className="sans"
|
||||
style={{
|
||||
flex: '0 0 300px',
|
||||
paddingLeft: indent,
|
||||
color: field.locked ? 'var(--ink)' : 'var(--head)',
|
||||
fontSize: '0.84rem',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={field.path}
|
||||
>
|
||||
{field.key}
|
||||
{field.locked && (
|
||||
<span className="dim" style={{ fontSize: '0.72rem' }}>
|
||||
{' '}
|
||||
· read-only
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
|
||||
if (field.type === 'object' || field.type === 'array') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
|
||||
>
|
||||
{field.key || '(the file)'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
|
||||
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.advanced) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
{label}
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{field.reason} — edit it in Raw JSON
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
{label}
|
||||
{field.type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
disabled={field.locked}
|
||||
onChange={(event) => onChange(field, event.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
type={field.secret && !revealed ? 'password' : 'text'}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
disabled={field.locked}
|
||||
onChange={(event) => onChange(field, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{field.secret && !field.locked && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
|
||||
{revealed ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** What the game said happened. The rollback case is the one worth reading. */
|
||||
function Report({ report }) {
|
||||
if (!report) return null
|
||||
|
||||
const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)'
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
|
||||
{report.rolledBack
|
||||
? 'The plugin did not come back, so the old file was put back automatically.'
|
||||
: report.reloaded
|
||||
? 'Saved, and the plugin reloaded.'
|
||||
: `Saved. ${report.reason || 'Nothing was reloaded.'}`}
|
||||
</p>
|
||||
{report.rolledBack && report.reason && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||
{report.reason}
|
||||
</p>
|
||||
)}
|
||||
{report.log && (
|
||||
<pre
|
||||
className="sans"
|
||||
style={{
|
||||
background: 'var(--line-soft)',
|
||||
padding: 10,
|
||||
marginTop: 8,
|
||||
fontSize: '0.74rem',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{report.log}
|
||||
</pre>
|
||||
)}
|
||||
{report.files.some((f) => f.rewritten) && (
|
||||
<Warn>
|
||||
The plugin rewrote the file as it loaded — both frameworks add any settings a config is
|
||||
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ModConfig() {
|
||||
const [serverId, setServerId] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [tier, setTier] = useState('form')
|
||||
const [edits, setEdits] = useState({})
|
||||
const [raw, setRaw] = useState('')
|
||||
const [reload, setReload] = useState('')
|
||||
const [revealed, setRevealed] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [report, setReport] = useState(null)
|
||||
const [fileNonce, setFileNonce] = useState(0)
|
||||
|
||||
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
|
||||
|
||||
// The tree is asked for per server and never cached across one: what is on a
|
||||
// host's disk has no stale answer worth showing, and a plugin loaded a minute
|
||||
// ago has to be able to appear.
|
||||
const { data: tree, error: treeError } = useAsync(
|
||||
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
|
||||
[serverId],
|
||||
)
|
||||
|
||||
const { data: file, error: fileError } = useAsync(
|
||||
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
|
||||
[serverId, path, fileNonce],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setEdits({})
|
||||
setRevealed({})
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
// A freshly opened file starts from what the host holds: the raw editor's text
|
||||
// and the reload target's guess both come from the answer rather than from
|
||||
// whatever the previous file left behind.
|
||||
//
|
||||
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
|
||||
// whose value matches no `<option>` displays the first one, so a guess of
|
||||
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
|
||||
// reload itself — put "nothing — just write the file" on the screen while the
|
||||
// request carried `reload: RunicGateway`, and every save of our own config was
|
||||
// refused for a reason the page had just said did not apply.
|
||||
useEffect(() => {
|
||||
if (!file) return
|
||||
setRaw(file.text)
|
||||
|
||||
const offered = (tree ? tree.loaded : []).some(
|
||||
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
|
||||
)
|
||||
|
||||
setReload(offered ? file.plugin : '')
|
||||
reset()
|
||||
}, [file, tree, reset])
|
||||
|
||||
useEffect(() => {
|
||||
setPath('')
|
||||
setReport(null)
|
||||
}, [serverId])
|
||||
|
||||
if (serverError) return <ErrorState error={serverError} />
|
||||
if (!servers) return <Loading />
|
||||
|
||||
const rows = servers.servers || servers || []
|
||||
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setReport(null)
|
||||
|
||||
try {
|
||||
const body =
|
||||
tier === 'form'
|
||||
? {
|
||||
path,
|
||||
version: file.version,
|
||||
...(reload ? { reload } : {}),
|
||||
// A number goes up as the TEXT that was typed. `2.50` stays
|
||||
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
|
||||
// number here is precisely the bug the server half exists to
|
||||
// avoid, and it would be reintroduced in the browser.
|
||||
edits: Object.values(edits).map(({ field, value }) =>
|
||||
field.type === 'number'
|
||||
? { pointer: field.pointer, raw: String(value) }
|
||||
: { pointer: field.pointer, value },
|
||||
),
|
||||
}
|
||||
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
|
||||
|
||||
const answer = await api.adminConfig.save(serverId, body)
|
||||
|
||||
setReport(answer.report || null)
|
||||
if (!answer.changed) setError('Nothing changed, so nothing was written.')
|
||||
|
||||
// Re-read either way: a successful reload usually rewrites the file with
|
||||
// the defaults it was missing, and a rollback means what is on disk is no
|
||||
// longer what is on the screen.
|
||||
setFileNonce((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That save did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = Object.keys(edits).length
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 980 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
These are the configuration files on the game host itself, read live through the bridge. A
|
||||
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
|
||||
file back automatically</strong> if the plugin does not come back. The game’s data
|
||||
directory — kit cooldowns, zone definitions, the permission store — is not settings and is
|
||||
never listed here.
|
||||
</p>
|
||||
|
||||
<Card title="Server" subtitle={`${rows.length} configured`}>
|
||||
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
|
||||
<option value="">Choose a server…</option>
|
||||
{rows.map((row) => (
|
||||
<option key={row.id} value={row.id}>
|
||||
{row.name || row.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tree && tree.root && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
{tree.root}
|
||||
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{serverId && treeError && <ErrorState error={treeError} />}
|
||||
|
||||
{serverId && !treeError && !tree && <Loading />}
|
||||
|
||||
{tree && (
|
||||
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
|
||||
{tree.plugins.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
This server reports no configuration files.
|
||||
</p>
|
||||
)}
|
||||
{tree.plugins.map((group) => (
|
||||
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
|
||||
{group.title || group.plugin}
|
||||
</strong>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
|
||||
{group.isBridge ? ' · this bridge' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{group.files.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
disabled={!entry.editable}
|
||||
onClick={() => {
|
||||
setPath(entry.path)
|
||||
setReport(null)
|
||||
setTier('form')
|
||||
}}
|
||||
>
|
||||
{entry.path}
|
||||
</button>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{Math.round(entry.bytes / 102.4) / 10} KB
|
||||
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
|
||||
{entry.reason ? ` · ${entry.reason}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!group.loaded && (
|
||||
<Warn>
|
||||
Nothing on this server is loaded under that name, so a save here is written and
|
||||
not reloaded. It applies the next time the plugin loads.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{path && fileError && <ErrorState error={fileError} />}
|
||||
{path && !fileError && !file && <Loading />}
|
||||
|
||||
{file && (
|
||||
<Card
|
||||
title={file.path}
|
||||
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
onClick={() => setTier('form')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
onClick={() => setTier('raw')}
|
||||
>
|
||||
Raw JSON
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{file.parseError && (
|
||||
<Warn tone="#e05a5a">
|
||||
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
|
||||
draw a form from. Raw JSON is the tier that can fix it.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{file.isBridge && (
|
||||
<Warn>
|
||||
This is the bridge’s own configuration. Its address, port and server id are read-only
|
||||
here — changing any of them from the website would cut the link carrying the change,
|
||||
or strand every row this site holds for this server. They are editable on the host
|
||||
itself. This plugin also cannot be reloaded from here.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{tier === 'form' && file.fields && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{file.fields
|
||||
.filter((field) => field.path !== '')
|
||||
.map((field) => (
|
||||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
value={
|
||||
edits[field.path]
|
||||
? edits[field.path].value
|
||||
: field.type === 'number'
|
||||
? field.raw
|
||||
: field.value
|
||||
}
|
||||
onChange={change}
|
||||
revealed={Boolean(revealed[field.path])}
|
||||
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier === 'raw' && (
|
||||
<textarea
|
||||
className="input"
|
||||
spellCheck={false}
|
||||
value={raw}
|
||||
onChange={(event) => setRaw(event.target.value)}
|
||||
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
Reload
|
||||
</label>
|
||||
{/* A guess, and it says so. The folder a config sits in is convention
|
||||
rather than contract, so reloading it silently is how the wrong
|
||||
plugin gets reloaded, reports success, and the edited one never
|
||||
re-reads anything. */}
|
||||
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
|
||||
<option value="">nothing — just write the file</option>
|
||||
{(tree ? tree.loaded : [])
|
||||
.filter((p) => p.name !== tree.self)
|
||||
.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
|
||||
onClick={save}
|
||||
>
|
||||
{busy ? 'Saving…' : 'Save and reload'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Beside the button, not at the top of the page. A save is made at the
|
||||
bottom of a long form, and a refusal rendered above the fold is a
|
||||
click that visibly did nothing. */}
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Report report={report} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{serverId && <History serverId={serverId} nonce={fileNonce} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who changed what, including the saves that were refused or undone. */
|
||||
function History({ serverId, nonce }) {
|
||||
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
|
||||
|
||||
if (!data || !data.writes || data.writes.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
|
||||
{data.writes.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="sans"
|
||||
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
|
||||
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
|
||||
>
|
||||
{row.outcome}
|
||||
{row.reloaded ? ' · reloaded' : ''}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{ago(row.createdAt)}
|
||||
{row.tier === 'raw' ? ' · raw' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{(row.changes || []).map((change, index) => (
|
||||
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{change.path}
|
||||
{change.from !== null && change.to !== null ? `: ${change.from} → ${change.to}` : ''}
|
||||
</div>
|
||||
))}
|
||||
{row.detail && (
|
||||
<div className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{row.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
617
client/src/routes/admin/Permissions.jsx
Normal file
617
client/src/routes/admin/Permissions.jsx
Normal file
@@ -0,0 +1,617 @@
|
||||
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||||
//
|
||||
// R2's authoring surface, and this module's first admin page.
|
||||
//
|
||||
// **What is on it is decided by what an operator can get wrong**, rather than by
|
||||
// what the tables contain. Four states are invisible from the game and from a
|
||||
// list of grants, and every one of them looks exactly like success:
|
||||
//
|
||||
// • a grant against somebody who has linked no Steam account — authored,
|
||||
// stored, pushed nowhere;
|
||||
// • a permission no loaded plugin has registered — the grant lands silently
|
||||
// nowhere, because `GrantUserPermission` no-ops for an unregistered name;
|
||||
// • a group member who has never connected — the store has no user record to
|
||||
// put in a group yet, and the membership waits for their first connection;
|
||||
// • a server whose last sync failed — the site is authoritative and the game
|
||||
// has not heard it.
|
||||
//
|
||||
// So each of those is a sentence on this page rather than a number in a report.
|
||||
//
|
||||
// The screen never writes to a game. Every button here writes to the site and
|
||||
// the mirror's loop reconciles within seconds — except *Sync now*, which runs
|
||||
// that pass immediately because an operator who has just changed something
|
||||
// should not have to trust a timer to find out that a host is unreachable.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const FLEET = '*'
|
||||
|
||||
/** Shared furniture. The kit is nine exports and none of them is a table. */
|
||||
function Card({ title, subtitle, children, actions }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{actions}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ children, muted = false }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
color: muted ? 'var(--ink)' : 'var(--head)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Warn({ children }) {
|
||||
return (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function Scope({ value }) {
|
||||
return (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{value === FLEET ? 'every server' : value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One server's mirror state.
|
||||
*
|
||||
* `unresolved` and `pending` are rendered as sentences rather than counts
|
||||
* because each is a different problem with a different fix, and both are
|
||||
* invisible everywhere else on this page.
|
||||
*/
|
||||
function ServerState({ row, onSync, busy }) {
|
||||
const report = row.report || {}
|
||||
const unresolved = report.unresolved || []
|
||||
const pending = report.pending || []
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
{row.serverId}
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
|
||||
>
|
||||
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
|
||||
{busy ? 'Syncing…' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{row.error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||
{row.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unresolved.length > 0 && (
|
||||
<Warn>
|
||||
{unresolved.join(', ')} — no plugin loaded on this server has registered{' '}
|
||||
{unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '}
|
||||
{unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when
|
||||
the plugin is back.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<Warn>
|
||||
{pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a
|
||||
first connection — this server has never seen those players, so it has no account to put
|
||||
in a group yet.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A hand edit, with the two answers to it. */
|
||||
function DriftRow({ row, onAdopt, onRevoke, busy }) {
|
||||
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
|
||||
{row.serverId} · seen {ago(row.firstSeen)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||
Adopt
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||||
Revoke
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The memberships the game could not place yet, as `steamId:group`.
|
||||
*
|
||||
* Read out of each server's own report, because it is the only thing that knows:
|
||||
* a member who has never connected to a server has no user record there to put
|
||||
* in a group (§12.2 rule 4), and from every other angle they look like a member.
|
||||
* The server strip says how many; this is what puts it next to the person.
|
||||
*/
|
||||
function pendingSet(servers) {
|
||||
const pending = new Map()
|
||||
|
||||
for (const server of servers) {
|
||||
for (const entry of (server.report && server.report.pending) || []) {
|
||||
if (!pending.has(entry)) pending.set(entry, [])
|
||||
pending.get(entry).push(server.serverId)
|
||||
}
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [member, setMember] = useState('')
|
||||
const [permission, setPermission] = useState('')
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = (permissions) =>
|
||||
act(() =>
|
||||
api.adminPermissions.saveGroup(group.name, {
|
||||
title: group.title,
|
||||
rank: group.rank,
|
||||
scope: group.scope,
|
||||
permissions,
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={group.title || group.name}
|
||||
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="field-label">Permissions</div>
|
||||
{group.permissions.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
This group carries nothing, so being in it does nothing.
|
||||
</p>
|
||||
)}
|
||||
{group.permissions.map((perm) => (
|
||||
<Row key={perm}>
|
||||
<span style={{ flex: 1 }}>{perm}</span>
|
||||
{!catalogue.some((entry) => entry.permission === perm) && (
|
||||
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
|
||||
no server has registered this
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => save(group.permissions.filter((p) => p !== perm))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!permission.trim()) return
|
||||
save([...group.permissions, permission.trim().toLowerCase()])
|
||||
setPermission('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
list="rust-permission-names"
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Add permission
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="field-label" style={{ marginTop: 18 }}>
|
||||
Members
|
||||
</div>
|
||||
{group.members.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
Nobody is in this group.
|
||||
</p>
|
||||
)}
|
||||
{group.members.map((m) => {
|
||||
const waiting = m.accounts
|
||||
.map((account) => pending.get(`${account.steamId}:${group.name}`))
|
||||
.filter(Boolean)
|
||||
.flat()
|
||||
|
||||
return (
|
||||
<Row key={m.userId}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{m.username}
|
||||
{m.accounts.length > 0 ? (
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· has linked no Steam account, so this reaches nobody
|
||||
</span>
|
||||
)}
|
||||
{waiting.length > 0 && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!member.trim()) return
|
||||
act(() => api.adminPermissions.addMember(group.name, member.trim()))
|
||||
setMember('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="website username"
|
||||
value={member}
|
||||
onChange={(event) => setMember(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Add member
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{servers.length > 1 && group.scope !== FLEET && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
This group exists on {group.scope} only. The other servers never receive it.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Permissions() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ name: '', title: '', scope: FLEET })
|
||||
const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET })
|
||||
|
||||
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const servers = data.servers || []
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
{/* No heading of our own: core's admin chrome already draws the route's
|
||||
title above the page, and a second one is the same words twice. */}
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
This site is the author of record. Groups and grants written here are pushed into each
|
||||
server’s own permission store, so every plugin that checks a permission honours them — and a
|
||||
wipe does not lose them, because they are re-pushed when the server comes back.
|
||||
</p>
|
||||
|
||||
{/* The option source, shared by both forms. A datalist rather than a select:
|
||||
a name that no server has registered is still authorable — the plugin
|
||||
may simply not be loaded right now — and the warning beside it is the
|
||||
honest treatment, where a closed list would be a refusal. */}
|
||||
<datalist id="rust-permission-names">
|
||||
{(data.catalogue || []).map((entry) => (
|
||||
<option key={entry.permission} value={entry.permission} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title="Servers"
|
||||
subtitle={`${servers.length} configured`}
|
||||
actions={
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
|
||||
Sync all
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{servers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
No servers are configured yet, so nothing written here reaches a game.
|
||||
</p>
|
||||
)}
|
||||
{servers.map((row) => (
|
||||
<ServerState
|
||||
key={row.serverId}
|
||||
row={row}
|
||||
busy={busy}
|
||||
onSync={(id) => act(() => api.adminPermissions.sync(id))}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{(data.drift || []).length > 0 && (
|
||||
<Card
|
||||
title="Changed in game"
|
||||
subtitle="granted at a console, not by this site"
|
||||
>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
|
||||
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
|
||||
the next sync.
|
||||
</p>
|
||||
{data.drift.map((row) => (
|
||||
<DriftRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
busy={busy}
|
||||
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
|
||||
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Direct grants" subtitle="one person, one permission">
|
||||
{(data.grants || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
Nobody holds a permission of their own yet.
|
||||
</p>
|
||||
)}
|
||||
{(data.grants || []).map((row) => (
|
||||
<Row key={row.id}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
|
||||
<Scope value={row.scope} />
|
||||
{row.accounts.length === 0 && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· has linked no Steam account, so this reaches nobody
|
||||
</span>
|
||||
)}
|
||||
{/* The same warning the group's permission list carries, and it
|
||||
matters more here: a grant naming a permission nothing has
|
||||
registered is the failure the plugin's pre-check exists for,
|
||||
and it is invisible on this row without it. */}
|
||||
{!(data.catalogue || []).some((entry) => entry.permission === row.permission) && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· no server has registered this permission
|
||||
</span>
|
||||
)}
|
||||
{row.source !== 'admin' && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!grant.username.trim() || !grant.permission.trim()) return
|
||||
act(() =>
|
||||
api.adminPermissions.grant({
|
||||
username: grant.username.trim(),
|
||||
permission: grant.permission.trim().toLowerCase(),
|
||||
scope: grant.scope,
|
||||
}),
|
||||
)
|
||||
setGrant({ username: '', permission: '', scope: FLEET })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="website username"
|
||||
value={grant.username}
|
||||
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
|
||||
style={{ flex: '1 1 160px' }}
|
||||
/>
|
||||
<input
|
||||
list="rust-permission-names"
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={grant.permission}
|
||||
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
|
||||
style={{ flex: '1 1 160px' }}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={grant.scope}
|
||||
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
|
||||
>
|
||||
<option value={FLEET}>every server</option>
|
||||
{servers.map((row) => (
|
||||
<option key={row.serverId} value={row.serverId}>
|
||||
{row.serverId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Grant
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{(data.groups || []).map((group) => (
|
||||
<GroupCard
|
||||
key={group.name}
|
||||
group={group}
|
||||
catalogue={data.catalogue || []}
|
||||
servers={servers}
|
||||
pending={pendingSet(servers)}
|
||||
onChanged={reload}
|
||||
setError={setError}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Card title="New group">
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!form.name.trim()) return
|
||||
act(() =>
|
||||
api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), {
|
||||
title: form.title.trim() || form.name.trim(),
|
||||
scope: form.scope,
|
||||
permissions: [],
|
||||
}),
|
||||
)
|
||||
setForm({ name: '', title: '', scope: FLEET })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="vip"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
style={{ flex: '1 1 140px' }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="VIP"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||
style={{ flex: '1 1 140px' }}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={form.scope}
|
||||
onChange={(event) => setForm({ ...form, scope: event.target.value })}
|
||||
>
|
||||
<option value={FLEET}>every server</option>
|
||||
{servers.map((row) => (
|
||||
<option key={row.serverId} value={row.serverId}>
|
||||
{row.serverId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
A group is created in each in-scope game as a real group, so plugins that read group
|
||||
membership see it. A member who has never connected to a server joins it there on their
|
||||
first connection — a direct grant reaches them straight away, which is the difference
|
||||
worth knowing when somebody is waiting.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
client/src/routes/admin/UserRustSections.jsx
Normal file
284
client/src/routes/admin/UserRustSections.jsx
Normal file
@@ -0,0 +1,284 @@
|
||||
// ── This module's fill for `admin.users.detail` ───────────────────────────
|
||||
//
|
||||
// R13's first slot, and the phase criterion as an operator meets it: the Steam
|
||||
// id inside core's own user page, under core's own security panel.
|
||||
//
|
||||
// **The slot hands over `userId` and nothing else** — not a client. So this file
|
||||
// builds its own bindings for the routes the server half registered
|
||||
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
|
||||
// a call belong to the same module even when the URL between them is core's.
|
||||
//
|
||||
// **Most users have no Rust account, so most of the time this renders nothing.**
|
||||
// A panel that announced "no linked Steam accounts" on every user page in a
|
||||
// community that also runs a UO shard would be noise on the overwhelming
|
||||
// majority of them. Silence is the honest answer to "what does the Rust module
|
||||
// know about this person" when it is nothing.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ago, count, duration } from '../../lib/format.js'
|
||||
import { useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's all-time totals for this player. */
|
||||
function ServerRow({ server }) {
|
||||
return (
|
||||
<li
|
||||
className="sans"
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
|
||||
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** One linked Steam account: who it is, when it was linked, and the way out. */
|
||||
function LinkPanel({ userId, link, onRemoved }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function unlink() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminUserLinks.remove(userId, link.steamId)
|
||||
await onRemoved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink that account.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{link.name || link.steamId}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{link.steamId} · linked {ago(link.linkedAt)}
|
||||
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
|
||||
</div>
|
||||
{/* Worth showing only when they differ: the name on the link is what
|
||||
they were called when they linked, the other is what the game last
|
||||
saw. A rename is the ordinary reason, and an operator reading a
|
||||
support ticket wants both names. */}
|
||||
{link.linkedName && link.name && link.linkedName !== link.name && (
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
Linked as “{link.linkedName}”.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{link.servers.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: '12px 0 0',
|
||||
padding: '12px 0 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{link.servers.map((server) => (
|
||||
<ServerRow key={server.serverId} server={server} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7's half of the panel: what this person may do in game.
|
||||
*
|
||||
* It renders whenever they hold anything, INCLUDING when they have linked no
|
||||
* Steam account — which is the one case worth going out of the way for. A grant
|
||||
* against an unlinked person is authored, stored, pushed nowhere, and identical
|
||||
* to a working one everywhere except here.
|
||||
*/
|
||||
function PermissionsPanel({ userId, data, onChanged }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [permission, setPermission] = useState('')
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const nothing = data.groups.length === 0 && data.grants.length === 0
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Permissions
|
||||
</div>
|
||||
|
||||
{nothing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
Nothing granted.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.groups.map((group) => (
|
||||
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
group · {group.scope === '*' ? 'every server' : group.scope}
|
||||
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.grants.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
|
||||
>
|
||||
<span style={{ flex: 1, color: 'var(--head)' }}>
|
||||
{row.permission}{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{row.scope === '*' ? 'every server' : row.scope}
|
||||
{row.source !== 'admin' ? ` · ${row.source}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
|
||||
style={{ flex: 'none' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!nothing && data.reaches.length === 0 && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
|
||||
itself when they link.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!permission.trim()) return
|
||||
act(() =>
|
||||
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
|
||||
)
|
||||
setPermission('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Grant
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserRustSections({ userId }) {
|
||||
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
||||
// re-reads after its own write (the same shape the player page uses).
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
|
||||
const { data: permissions } = useAsync(
|
||||
() => api.adminUserPermissions.list(userId),
|
||||
[userId, reloads],
|
||||
)
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
|
||||
// somebody else's page: a spinner on every user page for a module most users
|
||||
// have nothing to do with is worse than a section that appears when it has
|
||||
// something, and a failure here must not replace core's own user detail with an
|
||||
// error card.
|
||||
// **Both reads decide whether this section exists**, and the second one is the
|
||||
// reason. A browser walk found it: a person can hold permissions and have
|
||||
// linked no Steam account — which is exactly the state an operator most needs
|
||||
// to see, because it is the one that reaches nobody — and a section gated on
|
||||
// links alone hides it completely.
|
||||
const holdsSomething =
|
||||
permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
|
||||
|
||||
if (!data || (data.links.length === 0 && !holdsSomething)) return null
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Rust</SectionTitle>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{data.links.map((link) => (
|
||||
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
|
||||
))}
|
||||
|
||||
{data.links.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
|
||||
recorded in the activity log — it is the way back for a player who linked the wrong
|
||||
account and cannot reach it in game.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Inside the same section rather than beside it: "who is this in game"
|
||||
and "what may they do there" are one question asked twice, and an
|
||||
operator reading a support ticket has both in front of them. The note
|
||||
above belongs to the links, so it sits with them rather than under
|
||||
the panel it would otherwise appear to describe. */}
|
||||
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
191
client/src/routes/player/Account.jsx
Normal file
191
client/src/routes/player/Account.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
// ── The player's own Rust identity ────────────────────────────────────────
|
||||
//
|
||||
// `/player/rust` — where a signed-in player links the Steam account they play
|
||||
// on. It is the one page in this module a player is asked to *do* something on,
|
||||
// and the thing they are doing matters more than it looks: from phase 7 the link
|
||||
// is what in-game permissions are granted against, and from phase 13 it is what
|
||||
// rewards are handed to.
|
||||
//
|
||||
// **A player route renders no layout of its own.** Core wraps `/player/*` in its
|
||||
// own portal chrome, so this page starts at a heading — unlike the public pages
|
||||
// in this module, which render `PublicLayout` themselves.
|
||||
//
|
||||
// The three-step instruction at the top is not decoration. Nothing else on the
|
||||
// site tells a player that the code comes from the game, and a code field with no
|
||||
// explanation is a code field nobody can use.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago, shortId } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
/** The code field, and the four answers it can produce. */
|
||||
function LinkForm({ onLinked }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (!code.trim() || busy) return
|
||||
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await api.playerLinks.confirm(code.trim())
|
||||
setMessage(
|
||||
result.already
|
||||
? 'That account was already linked to you.'
|
||||
: `Linked ${result.link.name || shortId(result.link.steamId)}.`,
|
||||
)
|
||||
setCode('')
|
||||
await onLinked()
|
||||
} catch (err) {
|
||||
// Every refusal the server sends is already a sentence aimed at a player —
|
||||
// "run /link again", "run /unlink in game", "try again in a minute" — so
|
||||
// this renders it rather than replacing it with one of its own. The three
|
||||
// are not interchangeable, and a page that flattened them into "could not
|
||||
// link that code" would send a player back to the server that is down.
|
||||
setError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: 18 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label" style={{ display: 'block', marginBottom: 6 }}>Link code</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="K7M2PQ"
|
||||
// The plugin's alphabet has no O, 0, I or 1, so a player reading a
|
||||
// code off their screen cannot produce one — but they can type a
|
||||
// lowercase one, and the code is matched case-insensitively at the
|
||||
// other end. Upper-casing here makes what they typed look like what
|
||||
// they were shown.
|
||||
maxLength={12}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="input"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', width: 160 }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy || !code.trim()}>
|
||||
{busy ? 'Checking…' : 'Link account'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className="sans" style={{ color: '#7fd0a4', fontSize: '0.86rem', margin: '10px 0 0' }}>{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.86rem', margin: '10px 0 0' }}>{error}</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** One linked account, and the control that releases it. */
|
||||
function LinkRow({ link, onRemoved }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function remove() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.playerLinks.remove(link.steamId)
|
||||
await onRemoved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink that account.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{link.name || shortId(link.steamId)}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{link.steamId} · linked {ago(link.linkedAt)}
|
||||
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
|
||||
// the person looking at it changes it, and a page that re-asked every twenty
|
||||
// seconds would be asking a question nobody is waiting on.
|
||||
//
|
||||
// **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and
|
||||
// that is the whole of its interface — so a counter in the deps is how a page
|
||||
// re-reads after its own write. It blanks while it re-reads, which is right
|
||||
// here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`).
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads])
|
||||
const links = data ? data.links : []
|
||||
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>Steam accounts</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', maxWidth: '60ch' }}>
|
||||
Linking tells this site which Steam account is yours, so your play on our servers appears
|
||||
under your name here — and so rewards and permissions the site hands out can reach you in
|
||||
game.
|
||||
</p>
|
||||
|
||||
<ol className="sans dim" style={{ fontSize: '0.86rem', marginTop: 14, paddingLeft: 20, maxWidth: '60ch' }}>
|
||||
<li>Join any of our Rust servers and type <code>/link</code> in chat.</li>
|
||||
<li>The server replies with a six-character code, only you can see it, and it lasts five minutes.</li>
|
||||
<li>Type it below. It works once.</li>
|
||||
</ol>
|
||||
|
||||
<LinkForm onLinked={reload} />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState error={error} />}
|
||||
|
||||
{data && links.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: '22px 0 0', padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{links.map((link) => (
|
||||
<LinkRow key={link.steamId} link={link} onRemoved={reload} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && links.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 14, maxWidth: '60ch' }}>
|
||||
A link covers every server this community runs — a Steam account is one person wherever
|
||||
they play, while stats are kept per server and per wipe. You can also type
|
||||
{' '}<code>/unlink</code> in game to release one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && links.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 18 }}>
|
||||
No Steam account is linked to this profile yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
client/src/routes/public/ServerDetail.jsx
Normal file
184
client/src/routes/public/ServerDetail.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
// ── One server ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// R8's page beneath the landing page, and the phase-4 criterion lives here: it
|
||||
// renders the last thing this server said while every server is off. Nothing on
|
||||
// it is a live call to a game host — every panel reads this module's own tables,
|
||||
// filled by the ingest cursor — so a shard that has been down for a week renders
|
||||
// a week-old killfeed and a leaderboard that is still correct, rather than an
|
||||
// error page.
|
||||
//
|
||||
// ── Everything selectable is in the URL ───────────────────────────────────
|
||||
//
|
||||
// Tab, feed filter, wipe and leaderboard sort all live in search parameters.
|
||||
// That costs a little ceremony here and buys the thing a community site is for:
|
||||
// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState`
|
||||
// would make every one of those sentences unlinkable, lose the reader's place on
|
||||
// a refresh, and make the browser's back button leave the page instead of
|
||||
// undoing what they just clicked.
|
||||
//
|
||||
// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is
|
||||
// the same live navigation context core's own pages use. A module with its own
|
||||
// copy of react-router would get a `useParams` that returns nothing on a page
|
||||
// that otherwise renders perfectly — see `core.js`'s identity check.
|
||||
|
||||
import { useSearchParams, useParams, Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import Feed from '../../components/Feed.jsx'
|
||||
import Leaderboard from '../../components/Leaderboard.jsx'
|
||||
import Online from '../../components/Online.jsx'
|
||||
import Tabs from '../../components/Tabs.jsx'
|
||||
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
|
||||
import Wipes from '../../components/Wipes.jsx'
|
||||
import { ago, count, day } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'feed', label: 'Feed' },
|
||||
{ id: 'leaderboard', label: 'Leaderboard' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'wipes', label: 'Wipes' },
|
||||
]
|
||||
|
||||
export default function ServerDetail() {
|
||||
const { id } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const { data, loading, error } = useAsync(() => api.servers.get(id), [id])
|
||||
const server = data ? data.server : null
|
||||
|
||||
const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed'
|
||||
const filter = params.get('show') || 'all'
|
||||
const sort = params.get('sort') || 'kills'
|
||||
|
||||
// `wipe` absent means all time; `wipe=current` means whatever wipe the server
|
||||
// is on now, which is a moving target and therefore a word rather than an id —
|
||||
// a link somebody shares stays about "now" rather than about the map that was
|
||||
// current when they sent it.
|
||||
const wipeParam = params.get('wipe')
|
||||
const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam
|
||||
|
||||
const set = (key, value) => {
|
||||
const next = new URLSearchParams(params)
|
||||
if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key)
|
||||
else next.set(key, value)
|
||||
// `replace` so that flipping between tabs does not fill the reader's history
|
||||
// with one entry per click — back should leave the page they arrived on.
|
||||
setParams(next, { replace: true })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<Loading />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// A 404 from the detail route is the one answer the other four cannot give:
|
||||
// an unknown id has no events, no leaderboard and nobody online, and each of
|
||||
// those empty lists is a perfectly good answer to its own question. So this is
|
||||
// where "there is no such server" is said.
|
||||
//
|
||||
// **A mistyped address is not a fault, and must not be dressed as one.** The
|
||||
// first version of this page rendered core's `ErrorState` under the heading and
|
||||
// the result read "No such server / Something went wrong" — which sends a
|
||||
// reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for
|
||||
// the case it is for: a request that failed for a reason nobody can see.
|
||||
if (error || !server) {
|
||||
const missing = !error || error.status === 404
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
title={missing ? 'No such server' : 'That server could not be loaded'}
|
||||
lead={
|
||||
missing
|
||||
? 'This address does not name a server this site follows.'
|
||||
: 'The site could not read this server just now. It is worth trying again.'
|
||||
}
|
||||
/>
|
||||
{!missing && <ErrorState error={error} />}
|
||||
<p className="sans" style={{ marginTop: 20 }}>
|
||||
<Link to="/rust">Back to the server list</Link>
|
||||
</p>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
eyebrow="Rust"
|
||||
title={server.name}
|
||||
lead={describeWorld(server)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
|
||||
>
|
||||
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
||||
{server.online
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</span>
|
||||
{/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site
|
||||
last wrote the row, which a FAILED poll does too. Reading the second
|
||||
as the first is what made an offline server claim it had reported just
|
||||
now, every thirty seconds, for as long as it stayed down. */}
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
|
||||
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
|
||||
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<WipeSelect
|
||||
serverId={server.id}
|
||||
value={wipeParam}
|
||||
currentWipeId={server.wipeId}
|
||||
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
|
||||
|
||||
{tab === 'feed' && (
|
||||
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
|
||||
)}
|
||||
|
||||
{tab === 'leaderboard' && (
|
||||
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
|
||||
)}
|
||||
|
||||
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
|
||||
|
||||
{tab === 'wipes' && (
|
||||
<Wipes
|
||||
serverId={server.id}
|
||||
currentWipeId={server.wipeId}
|
||||
selected={wipeId}
|
||||
// Picking a wipe here is a navigation as much as a filter: it is the
|
||||
// question "what happened during that map", and the answer is the feed.
|
||||
onSelect={(value) => {
|
||||
const next = new URLSearchParams(params)
|
||||
next.set('wipe', value)
|
||||
next.delete('tab')
|
||||
setParams(next, { replace: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** The world line under the heading — the things a Rust player asks first. */
|
||||
function describeWorld(server) {
|
||||
const parts = [
|
||||
server.level || null,
|
||||
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||
server.seed ? `seed ${server.seed}` : null,
|
||||
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'
|
||||
}
|
||||
@@ -1,43 +1,45 @@
|
||||
// ── The server list ───────────────────────────────────────────────────────
|
||||
// ── The server list, and the module's landing page ────────────────────────
|
||||
//
|
||||
// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath
|
||||
// it. The route is registered with an empty path in `entry.jsx` — core turns
|
||||
// that into the module's own namespace root — so this page's address is the one
|
||||
// an operator links to when they mean "our Rust servers".
|
||||
//
|
||||
// An ordinary React component. Nothing about being inside a module changes how
|
||||
// you write one — the only differences are where React comes from (core, via the
|
||||
// you write one; the only differences are where React comes from (core, via the
|
||||
// aliases in `vite.config.js`, so the import below looks completely normal and is
|
||||
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
|
||||
//
|
||||
// **Render `PublicLayout` yourself.** Core wraps public routes in its maintenance
|
||||
// gate and nothing else, so a page that omits the layout renders bare — no
|
||||
// header, no footer, no site chrome — which looks like a bug and is the contract
|
||||
// (§3.3). Admin and player routes are the other way round: core wraps those.
|
||||
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
|
||||
// routes in its maintenance gate and nothing else, so a page that omits the
|
||||
// layout renders bare; without a `shell` it renders full-bleed with the footer
|
||||
// riding up underneath it. Name a width, never a class — the classes are core's
|
||||
// (MODULE_API.md §3.3).
|
||||
//
|
||||
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
|
||||
// centred column, the vertical padding, and the thing that holds the footer at
|
||||
// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a
|
||||
// width, never a class, because the classes belong to core's stylesheet.
|
||||
//
|
||||
// This is the phase-1 version of the landing page R8 calls for. It lists servers
|
||||
// and links nowhere yet — `/rust/servers/:id` is the next phase's work — so it is
|
||||
// deliberately a table and not a design.
|
||||
// **This page never calls a game server.** Every field it renders comes from
|
||||
// this module's own tables, written by the ingest cursor, which is what lets it
|
||||
// render "offline, last seen an hour ago" instead of an error page when a shard
|
||||
// is down. The site's availability does not depend on the game's.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import { ago, count, day } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||
// one fewer thing an operator ships.
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
/** The "last reported" line, which has three cases and not one. */
|
||||
function reported(server) {
|
||||
if (!server.lastSeenAt) return 'This server has never reported.'
|
||||
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
|
||||
return `Last reported ${ago(server.lastSeenAt)}.`
|
||||
}
|
||||
|
||||
export default function Servers() {
|
||||
// `useAsync` is core's fetch/loading/error hook, and the components below are
|
||||
// its states. Using them rather than rolling your own is what makes a module
|
||||
// page indistinguishable from a core one while it loads and while it fails.
|
||||
//
|
||||
// It loads once, deliberately. The DETAIL page polls, because that is where
|
||||
// somebody watching a server sits; a list is a place people pass through.
|
||||
const { data, loading, error } = useAsync(() => api.servers.list(), [])
|
||||
const servers = data ? data.servers : []
|
||||
|
||||
@@ -66,37 +68,54 @@ export default function Servers() {
|
||||
)}
|
||||
|
||||
{servers.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
// The whole row is the link. A server's name being the only clickable
|
||||
// part is the thing people miss on a list of cards, and `a.card`
|
||||
// already carries core's own hover treatment.
|
||||
<Link
|
||||
key={server.id}
|
||||
to={`/rust/servers/${encodeURIComponent(server.id)}`}
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: '1px solid rgba(128,128,128,0.25)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
|
||||
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
|
||||
{/* `stale` is a first-class part of the answer rather than
|
||||
something the page infers from a timestamp. The server
|
||||
decides what counts as stale, because the server is what
|
||||
knows how often a sidecar is supposed to check in. */}
|
||||
Last reported {ago(server.updatedAt)}
|
||||
{server.stale ? ' — out of date, so it is shown as offline.' : '.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
{[
|
||||
server.level || null,
|
||||
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
|
||||
{/* `lastSeenAt`, never `updatedAt`. The second is when THIS
|
||||
site last wrote the row — which a failed poll does too — so
|
||||
a page reading it told a reader that a server down for three
|
||||
days had reported just now. And `stale` is a first-class
|
||||
part of the answer rather than something inferred from a
|
||||
timestamp: the server decides what counts as stale, because
|
||||
the server knows how often a sidecar is supposed to check in. */}
|
||||
{reported(server)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
|
||||
>
|
||||
{server.online
|
||||
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
141
client/test/feed.test.js
Normal file
141
client/test/feed.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// ── The feed's sentences ──────────────────────────────────────────────────
|
||||
//
|
||||
// `lib/feed.js` is the one part of the client half with real branching in it, and
|
||||
// it is pure on purpose so that a DOM-less runner can ask all of it. Everything
|
||||
// here is a claim about what a reader sees for a given frame — which is exactly
|
||||
// the kind of thing that rots silently, because a wrong killfeed line is still a
|
||||
// killfeed line.
|
||||
//
|
||||
// The fixtures are the frames the bridge plugin actually emits (its
|
||||
// `DescribeAttacker`, and PROTOCOL.md §8.4), not invented shapes.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, FEED_KINDS, FILTERS, kindsFor } from '../src/lib/feed.js'
|
||||
|
||||
const row = (kind, frame = {}) => ({ id: 1, kind, t: Date.now(), wipeId: 'w1', steamId: '7656', frame })
|
||||
|
||||
test('a player kill names the killer and the victim, in that order', () => {
|
||||
const line = describe(row('player.death', {
|
||||
name: 'Bob',
|
||||
attackerType: 'player',
|
||||
attackerName: 'Alice',
|
||||
weapon: 'rifle.ak',
|
||||
distance: 42.4,
|
||||
grid: 'H7',
|
||||
}))
|
||||
|
||||
assert.equal(line.tone, 'kill')
|
||||
assert.equal(line.actor, 'Alice')
|
||||
assert.equal(line.verb, 'killed')
|
||||
assert.equal(line.subject, 'Bob')
|
||||
assert.match(line.detail, /rifle ak/)
|
||||
assert.match(line.detail, /42m/)
|
||||
assert.match(line.detail, /H7/)
|
||||
})
|
||||
|
||||
test('the four attacker types are four different sentences', () => {
|
||||
// The plugin distinguishes them precisely so a reader does not have to guess
|
||||
// from an absent field, and collapsing any two loses something: a fall reported
|
||||
// as a kill by nobody is the failure this prevents.
|
||||
const victim = { name: 'Bob' }
|
||||
|
||||
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
|
||||
assert.equal(npc.actor, 'scientistnpc full any')
|
||||
assert.equal(npc.subject, 'Bob')
|
||||
|
||||
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))
|
||||
assert.equal(self.actor, 'Bob')
|
||||
assert.equal(self.subject, null)
|
||||
assert.match(self.verb, /own hand/)
|
||||
|
||||
const environment = describe(row('player.death', { ...victim, attackerType: 'environment' }))
|
||||
assert.equal(environment.actor, 'Bob')
|
||||
assert.equal(environment.verb, 'died')
|
||||
assert.equal(environment.subject, null)
|
||||
|
||||
// `HitInfo` is legitimately null on the environment path, so a death frame with
|
||||
// NO attacker type at all is that case — not a missing field to render around.
|
||||
const bare = describe(row('player.death', victim))
|
||||
assert.equal(bare.verb, 'died')
|
||||
assert.equal(bare.subject, null)
|
||||
})
|
||||
|
||||
test('a sleeping victim is said to have been sleeping', () => {
|
||||
const line = describe(row('player.death', { name: 'Bob', attackerType: 'player', attackerName: 'Alice', sleeping: true }))
|
||||
assert.match(line.detail, /while sleeping/)
|
||||
})
|
||||
|
||||
test('a disconnect with no session length says nothing about one', () => {
|
||||
// The plugin OMITS `sessionSec` for a player who was already on when it loaded:
|
||||
// an unknown session is not a session of no length. A line reading "after 0s"
|
||||
// would be a lie this module invented.
|
||||
const unknown = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit' }))
|
||||
assert.equal(unknown.detail, 'Quit')
|
||||
|
||||
const known = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit', sessionSec: 3720 }))
|
||||
assert.equal(known.detail, 'Quit · after 1h 2m')
|
||||
})
|
||||
|
||||
test('a chat line carries the message as text, never as markup', () => {
|
||||
// The message is the one field on this wire whose bytes a player chooses. It
|
||||
// comes back as a STRING and is rendered as a React child, which escapes it;
|
||||
// this test is here so that a later "render the message with formatting" idea
|
||||
// has to delete an explicit assertion rather than quietly change behaviour.
|
||||
const line = describe(row('player.chat', { name: 'Bob', message: '<img src=x onerror=alert(1)>', channel: 'Global' }))
|
||||
assert.equal(line.verb, '<img src=x onerror=alert(1)>')
|
||||
assert.equal(typeof line.verb, 'string')
|
||||
// Global is the default channel and saying so on every line is noise; Team is
|
||||
// information.
|
||||
assert.equal(line.detail, '')
|
||||
assert.equal(describe(row('player.chat', { name: 'B', message: 'hi', channel: 'Team' })).detail, 'Team')
|
||||
|
||||
// A chat row is the one line where the actor is a speaker rather than a
|
||||
// subject, and "Brannock see you in september" is not a sentence anybody
|
||||
// writes. The colon is presentation, so it lives here and not inside the text
|
||||
// the player typed.
|
||||
assert.equal(line.join, ': ')
|
||||
assert.equal(describe(row('player.connected', { name: 'B' })).join, undefined)
|
||||
})
|
||||
|
||||
test('an unknown kind renders as itself rather than vanishing', () => {
|
||||
// A later protocol adds kinds, and a module may be older than the game host it
|
||||
// is reading. The server's allowlist has already decided the row may be seen;
|
||||
// dropping it here would make the page quietly say less than the truth.
|
||||
const line = describe(row('player.teleported', { name: 'Bob' }))
|
||||
assert.equal(line.verb, 'player.teleported')
|
||||
assert.equal(line.tone, 'other')
|
||||
})
|
||||
|
||||
test('the feed never asks for the aggregate kind', () => {
|
||||
// `player.tally` is public and is flushed once a minute per active player
|
||||
// (§8.6). A feed that included it would be mostly wood counts; it is the
|
||||
// leaderboard's input, and that is where it shows up.
|
||||
assert.ok(!FEED_KINDS.includes('player.tally'))
|
||||
for (const filter of FILTERS) {
|
||||
for (const kind of filter.kinds) {
|
||||
assert.ok(FEED_KINDS.includes(kind), `filter "${filter.id}" asks for ${kind}, which the feed does not carry`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every kind the feed asks for is one the public route will serve', () => {
|
||||
// Held against the module's own allowlist rather than against a copy of it: a
|
||||
// kind this file asked for and `server/catalogue.js` refuses is a filter that
|
||||
// silently returns nothing, which reads as a quiet server.
|
||||
//
|
||||
// A CommonJS file from the server half, read by an ESM test through
|
||||
// `createRequire`. Crossing the two halves is fine HERE and nowhere else:
|
||||
// `test/` is not shipped, and `scripts/checkImports.js` governs what is.
|
||||
const catalogue = createRequire(import.meta.url)('../../server/catalogue.js')
|
||||
for (const kind of FEED_KINDS) {
|
||||
assert.ok(catalogue.PUBLIC_KINDS.includes(kind), `the feed asks for ${kind}, which is not public`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown filter falls back to everything rather than to nothing', () => {
|
||||
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
|
||||
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
|
||||
})
|
||||
96
client/test/format.test.js
Normal file
96
client/test/format.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ── Formatting ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Small functions, and the tests are small too — but three of them guard claims
|
||||
// that would otherwise be made by a page that looks fine: an unknown duration
|
||||
// rendered as zero, a timestamp in the wrong unit, and "in 0 seconds".
|
||||
//
|
||||
// Locale-dependent output is asserted loosely on purpose. `Intl` formats to the
|
||||
// RUNNER's locale, and a test pinned to "3 minutes ago" would be a test that
|
||||
// fails on a machine set to French while the page it describes is correct.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||
|
||||
test('a relative time picks the unit that fits', () => {
|
||||
assert.match(ago(NOW - 3 * 60_000, NOW), /3/)
|
||||
assert.match(ago(NOW - 5 * 3600_000, NOW), /5/)
|
||||
assert.match(ago(NOW - 3 * 86400_000, NOW), /3/)
|
||||
})
|
||||
|
||||
test('"just now" rather than "in 0 seconds"', () => {
|
||||
// What `numeric: 'auto'` produces under a minute is not what anybody means,
|
||||
// and a feed row a few seconds old is the commonest row on the page.
|
||||
assert.equal(ago(NOW, NOW), 'just now')
|
||||
assert.equal(ago(NOW - 10_000, NOW), 'just now')
|
||||
})
|
||||
|
||||
test('both time shapes this module serves are accepted', () => {
|
||||
// `updatedAt` is an ISO string the model produced; an event's `t` is the
|
||||
// millisecond stamp the plugin put on the frame. A helper that took only one
|
||||
// would be a helper every caller has to remember the type for.
|
||||
assert.equal(ago('2026-09-16T11:57:00.000Z', NOW), ago(NOW - 3 * 60_000, NOW))
|
||||
})
|
||||
|
||||
test('a missing time is "never", not the epoch', () => {
|
||||
assert.equal(ago(null), 'never')
|
||||
assert.equal(ago(undefined), 'never')
|
||||
assert.equal(ago(''), 'never')
|
||||
assert.equal(day(null), 'unknown')
|
||||
})
|
||||
|
||||
test('an unknown duration is a dash, and a short one keeps its seconds', () => {
|
||||
// The distinction the plugin makes and this must not lose: `sessionSec` is
|
||||
// ABSENT for a player who was already on when it loaded, so zero and unknown
|
||||
// arrive at the same function and must not render the same way.
|
||||
assert.equal(duration(null), '—')
|
||||
assert.equal(duration(0), '—')
|
||||
assert.equal(duration(40), '40s')
|
||||
assert.equal(duration(90), '2m')
|
||||
assert.equal(duration(3720), '1h 2m')
|
||||
assert.equal(duration(7200), '2h')
|
||||
})
|
||||
|
||||
test('a prefab reads as words, without a lookup table', () => {
|
||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||
assert.equal(prefab('scientistnpc_full_any'), 'scientistnpc full any')
|
||||
assert.equal(prefab(null), '')
|
||||
})
|
||||
|
||||
test('a steam id is shortened without pretending to be a name', () => {
|
||||
assert.equal(shortId('76561198000000001'), '…000001')
|
||||
assert.equal(shortId(''), '')
|
||||
})
|
||||
|
||||
test('a count that is not a number is zero, never NaN on the page', () => {
|
||||
assert.equal(count(undefined), '0')
|
||||
assert.equal(count(null), '0')
|
||||
})
|
||||
|
||||
test("a feed row from another day carries its date, not just a time", () => {
|
||||
// Found by the page walk: with the feed filtered to the previous wipe, three
|
||||
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.
|
||||
// Today's rows stay bare, because a killfeed of today's fights does not want
|
||||
// the date on every line.
|
||||
// Asserted against `Intl` rather than against a literal: a 12-hour locale puts
|
||||
// letters in a bare time ("05:30 AM"), so "has letters in it" is not the test —
|
||||
// "is exactly the time, and nothing else" is.
|
||||
const time = (at) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const todayAt = NOW - 90 * 60_000
|
||||
assert.equal(clock(todayAt, NOW), time(todayAt))
|
||||
|
||||
const olderAt = NOW - 46 * 86400_000
|
||||
assert.ok(clock(olderAt, NOW).endsWith(time(olderAt)))
|
||||
assert.ok(clock(olderAt, NOW).length > time(olderAt).length, 'an older row carries no date')
|
||||
|
||||
// Yesterday counts as another day even when it is only a few hours back — the
|
||||
// boundary is the calendar, not a duration, because that is what a reader
|
||||
// means by "what time was that".
|
||||
const lateLastNight = Date.parse('2026-09-15T23:50:00')
|
||||
const earlyToday = Date.parse('2026-09-16T00:20:00')
|
||||
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
|
||||
})
|
||||
@@ -66,9 +66,22 @@ function fakeRg() {
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
registry: {
|
||||
// Core's own prefixing, character for character (client/src/modules/registry.js):
|
||||
// the leading separators of the module's path are stripped and so are the
|
||||
// TRAILING ones, which is what lets a module register `path: ''` and own its
|
||||
// namespace root — `/rust` rather than `/rust/`.
|
||||
//
|
||||
// This fake did the obvious `${id}/${path}` until phase 4, and the day a
|
||||
// module registered an index route it produced `rust/` while a real core
|
||||
// produced `rust`. The suite then failed the nav check for a link that works
|
||||
// perfectly in a browser. A fake that is nearly core is worse than one that
|
||||
// is obviously not: it fails on the truth.
|
||||
registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
|
||||
for (const r of list || []) {
|
||||
const path = `${id}/${String(r.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||
routes[area].push({ ...r, path, moduleId: id })
|
||||
}
|
||||
}
|
||||
},
|
||||
registerNav(id, { area, items }) {
|
||||
@@ -120,7 +133,12 @@ it('registers at least one route, namespaced under the module id', () => {
|
||||
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
for (const r of list) {
|
||||
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
|
||||
// Either the namespace root itself (a module's index route, `rust`) or
|
||||
// something under it (`rust/servers/:id`). `startsWith('rust/')` alone
|
||||
// would reject the root — and `startsWith('rust')` alone would accept a
|
||||
// hypothetical `rustling`, which is why this is spelled out.
|
||||
const under = r.path === manifest.id || r.path.startsWith(`${manifest.id}/`)
|
||||
assert.ok(under, `${area} route "${r.path}" is not under the namespace`)
|
||||
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||
}
|
||||
}
|
||||
@@ -176,6 +194,19 @@ it('a nav row that gates on a feature has a provider to resolve it', () => {
|
||||
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('the footer slot core declares is filled, and by a component', () => {
|
||||
// R13's first slot, and the half that lives in the CHUNK: `site.footer.status`
|
||||
// is a CLIENT slot, so it cannot be named in `module.json`'s `extensions` —
|
||||
// that array is validated against the SERVER registry and naming a client slot
|
||||
// there fails the load outright. Nothing else holds this registration, and an
|
||||
// extension that stopped being registered is invisible: an unfilled slot
|
||||
// renders nothing, exactly as an uninstalled module does.
|
||||
const footer = registered.extensions.get('site.footer.status')
|
||||
assert.ok(footer, 'nothing fills site.footer.status')
|
||||
assert.equal(footer.id, manifest.id)
|
||||
assert.equal(typeof footer.Component, 'function')
|
||||
})
|
||||
|
||||
it('every slot module.json declares is one the chunk fills', () => {
|
||||
// `module.json` declares SERVER slots, and the loader validates those before
|
||||
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"admin": ["/rust"],
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"capabilities": ["servers"]
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
|
||||
}
|
||||
|
||||
@@ -1,16 +1,86 @@
|
||||
{
|
||||
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
|
||||
"routes": [
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/grants/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/rust/links/:steamId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/files",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/writes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions/catalogue",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/links",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/links",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/servers",
|
||||
@@ -21,6 +91,11 @@
|
||||
"path": "/api/v1/public/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/events",
|
||||
@@ -41,11 +116,56 @@
|
||||
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/grants",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name/members",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/sync",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/rust/link",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
|
||||
@@ -44,6 +44,7 @@ const core = require('./core')
|
||||
const db = require('./model/servers/servers.db')
|
||||
const eventsDb = require('./model/events/events.db')
|
||||
const ingest = require('./ingest')
|
||||
const permSync = require('./permSync')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
@@ -106,7 +107,12 @@ async function refreshOne(server) {
|
||||
// whose plugin is not loaded yet, and reporting it as unreachable sends the
|
||||
// operator to look at the network instead of at the game server.
|
||||
if (!board.ok) {
|
||||
await db.putState({ serverId: server.id, reachable: false, online: false })
|
||||
// `markUnreachable`, not `putState`: nothing answered, so the only new fact
|
||||
// is that nothing answered. Writing the whole row from that one fact would
|
||||
// blank the hostname, the map, the seed and the wipe — the last thing this
|
||||
// server said, which is exactly what the pages exist to render while it is
|
||||
// off.
|
||||
await db.markUnreachable(server.id, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ async function refreshOne(server) {
|
||||
// The sidecar is up and has never heard from the game. Presence is emptied
|
||||
// rather than left alone: a stale list of players on a server nobody can
|
||||
// reach is worse than an empty one, because it looks current.
|
||||
await db.putState({ serverId: server.id, reachable: true, online: false })
|
||||
await db.markUnreachable(server.id, true)
|
||||
await ingest.applyBoards(server.id, {})
|
||||
return
|
||||
}
|
||||
@@ -185,6 +191,11 @@ async function prune() {
|
||||
|
||||
async function onBoot() {
|
||||
await refresh()
|
||||
// The permission mirror owns its own loop and its own cadence (see
|
||||
// `permSync.js`). It is started rather than run here: a first pass would write
|
||||
// to every configured game server before the website had finished booting, and
|
||||
// nothing about R2 is urgent enough to delay a listener for.
|
||||
permSync.start()
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
||||
pruneTimer = setInterval(prune, PRUNE_MS)
|
||||
@@ -195,7 +206,7 @@ async function onBoot() {
|
||||
if (timer && typeof timer.unref === 'function') timer.unref()
|
||||
}
|
||||
|
||||
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS })
|
||||
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,6 +218,8 @@ async function onBoot() {
|
||||
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||
*/
|
||||
async function onShutdown() {
|
||||
permSync.stop()
|
||||
|
||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
|
||||
if (timer) clearInterval(timer)
|
||||
}
|
||||
|
||||
@@ -69,9 +69,20 @@ const STAFF_KINDS = Object.freeze([
|
||||
'player.unbanned',
|
||||
'player.login.attempt',
|
||||
'player.approved',
|
||||
// Protocol 3's two account frames. Neither carries a code — the code travels
|
||||
// through the player, which is what makes typing it proof — but both name a
|
||||
// Steam id ALONGSIDE a website account's activity, which is exactly the join a
|
||||
// public page must not be able to make: "this player is that person" is a fact
|
||||
// about somebody's identity, not about what happened on the server.
|
||||
'account.link.requested',
|
||||
'account.unlinked',
|
||||
// Protocol 4. Who holds which privilege in game, and the fact that somebody
|
||||
// changed it by hand — a question about a person's standing and about an
|
||||
// operator's own console, neither of which is a public page's business.
|
||||
'perm.drift',
|
||||
])
|
||||
|
||||
/** Every kind protocol 2 defines. */
|
||||
/** Every kind protocol 3 defines. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
|
||||
527
server/configEdit.js
Normal file
527
server/configEdit.js
Normal file
@@ -0,0 +1,527 @@
|
||||
// ── Editing a plugin's config without rewriting the numbers ───────────────
|
||||
//
|
||||
// R18's base tier generates a form from a config file's VALUES — a boolean
|
||||
// becomes a toggle, a number a field, a string a text box — so it works for
|
||||
// whatever plugins an operator happens to have installed, including ones added
|
||||
// after we shipped. This file is the half of that which cannot be done naively.
|
||||
//
|
||||
// ── The trap ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// **JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` yields
|
||||
// the number `1`, and `JSON.stringify` writes it back as `1`. Both frameworks
|
||||
// deserialize a config into typed C# classes, so a naive read-modify-write
|
||||
// silently rewrites every whole-numbered float as an integer — **on fields
|
||||
// nobody touched** — and Newtonsoft may coerce it or may throw. A throw at load
|
||||
// means the plugin does not come back, and R6/R17 make four of them required.
|
||||
//
|
||||
// The fields at risk are exactly the ones a Rust server tunes: gather rates,
|
||||
// multipliers, scales.
|
||||
//
|
||||
// ── So nothing here ever parses, mutates and re-serialises ────────────────
|
||||
//
|
||||
// `scan` is a JSON reader that records, for every value, the **span of source
|
||||
// text** it came from. `applyEdits` splices new literals into those spans and
|
||||
// leaves every other byte of the document exactly as it was — including the
|
||||
// author's indentation, key order, and the `.0` on a float nobody edited.
|
||||
//
|
||||
// Two rules fall out of that and both are deliberate:
|
||||
//
|
||||
// 1. **A number's new value arrives as the literal text an admin typed**, never
|
||||
// as a JavaScript number. `2.50` stays `2.50`; `1.0` stays `1.0`. The value
|
||||
// never becomes a `Number` anywhere in this module, which is the only way to
|
||||
// be sure it cannot be re-serialised into something else.
|
||||
// 2. **The generated form is type-preserving.** An edit may change what a value
|
||||
// IS, never what KIND of thing it is; changing a number into a string, or
|
||||
// adding a key, is a structural change and belongs in the raw-JSON tier,
|
||||
// where the admin is editing the document itself.
|
||||
//
|
||||
// Nothing in this file touches the network, a database, or core.
|
||||
|
||||
/** Value kinds this module names, in the language the form speaks. */
|
||||
const KINDS = ['object', 'array', 'string', 'number', 'boolean', 'null']
|
||||
|
||||
/**
|
||||
* A JSON number, by the grammar rather than by `Number()`.
|
||||
*
|
||||
* Used to judge a literal an admin typed. `Number('0x10')`, `Number('')` and
|
||||
* `Number(' 1 ')` are all happily finite and none of the three is JSON, so the
|
||||
* check has to be the grammar — which is also what keeps `1.0` and `1e3`
|
||||
* acceptable, since preserving those is the entire point.
|
||||
*/
|
||||
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
|
||||
|
||||
/**
|
||||
* Words that make a value a secret.
|
||||
*
|
||||
* Matched against the key split into WORDS, not as a substring: `Monkey` and
|
||||
* `Keybind` contain "key" and neither is a credential, and a config editor that
|
||||
* masked every third field would teach an operator to ignore the mask.
|
||||
*/
|
||||
const SECRET_WORDS = new Set([
|
||||
'key',
|
||||
'keys',
|
||||
'apikey',
|
||||
'token',
|
||||
'tokens',
|
||||
'secret',
|
||||
'secrets',
|
||||
'password',
|
||||
'passwd',
|
||||
'pass',
|
||||
'webhook',
|
||||
'webhooks',
|
||||
'credential',
|
||||
'credentials',
|
||||
'auth',
|
||||
])
|
||||
|
||||
class JsonScanError extends Error {}
|
||||
|
||||
/**
|
||||
* Reads `text` into a tree of nodes that remember where they came from.
|
||||
*
|
||||
* Every node carries `start` and `end`, the half-open span of the value in the
|
||||
* source. A caller that only wants the data can read `value`; a caller that
|
||||
* wants to CHANGE the data uses the span, because the span is the only thing
|
||||
* that survives a round trip unchanged.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {object} the root node
|
||||
* @throws {JsonScanError} with a position, on anything that is not JSON
|
||||
*/
|
||||
function scan(text) {
|
||||
const src = String(text)
|
||||
let at = 0
|
||||
|
||||
function fail(message) {
|
||||
throw new JsonScanError(`${message} at offset ${at}`)
|
||||
}
|
||||
|
||||
function ws() {
|
||||
while (at < src.length && (src[at] === ' ' || src[at] === '\t' || src[at] === '\n' || src[at] === '\r')) at += 1
|
||||
}
|
||||
|
||||
function literal(word, value) {
|
||||
if (src.startsWith(word, at)) {
|
||||
const start = at
|
||||
at += word.length
|
||||
return { type: word === 'null' ? 'null' : 'boolean', value, start, end: at }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function string() {
|
||||
const start = at
|
||||
at += 1 // the opening quote
|
||||
|
||||
let out = ''
|
||||
|
||||
while (at < src.length) {
|
||||
const ch = src[at]
|
||||
|
||||
if (ch === '"') {
|
||||
at += 1
|
||||
return { type: 'string', value: out, start, end: at }
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
const esc = src[at + 1]
|
||||
at += 2
|
||||
|
||||
if (esc === 'u') {
|
||||
const hex = src.slice(at, at + 4)
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape')
|
||||
out += String.fromCharCode(parseInt(hex, 16))
|
||||
at += 4
|
||||
} else if (esc === 'n') out += '\n'
|
||||
else if (esc === 't') out += '\t'
|
||||
else if (esc === 'r') out += '\r'
|
||||
else if (esc === 'b') out += '\b'
|
||||
else if (esc === 'f') out += '\f'
|
||||
else if (esc === '"' || esc === '\\' || esc === '/') out += esc
|
||||
else fail('bad escape')
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out += ch
|
||||
at += 1
|
||||
}
|
||||
|
||||
return fail('unterminated string')
|
||||
}
|
||||
|
||||
function number() {
|
||||
const start = at
|
||||
if (src[at] === '-') at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
if (src[at] === '.') {
|
||||
at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
}
|
||||
if (src[at] === 'e' || src[at] === 'E') {
|
||||
at += 1
|
||||
if (src[at] === '+' || src[at] === '-') at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
}
|
||||
|
||||
const raw = src.slice(start, at)
|
||||
if (!JSON_NUMBER.test(raw)) fail(`'${raw}' is not a number`)
|
||||
|
||||
// `raw` is the fact; `value` is a convenience for rendering and comparison,
|
||||
// and is never written back to the document.
|
||||
return { type: 'number', value: Number(raw), raw, start, end: at }
|
||||
}
|
||||
|
||||
function value() {
|
||||
ws()
|
||||
const ch = src[at]
|
||||
|
||||
if (ch === '{') return object()
|
||||
if (ch === '[') return array()
|
||||
if (ch === '"') return string()
|
||||
if (ch === '-' || (ch >= '0' && ch <= '9')) return number()
|
||||
|
||||
const lit = literal('true', true) || literal('false', false) || literal('null', null)
|
||||
if (lit) return lit
|
||||
|
||||
return fail('unexpected character')
|
||||
}
|
||||
|
||||
function object() {
|
||||
const start = at
|
||||
at += 1 // {
|
||||
const children = []
|
||||
ws()
|
||||
|
||||
if (src[at] === '}') {
|
||||
at += 1
|
||||
return { type: 'object', children, start, end: at }
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
ws()
|
||||
if (src[at] !== '"') fail('expected a key')
|
||||
const key = string()
|
||||
ws()
|
||||
if (src[at] !== ':') fail('expected a colon')
|
||||
at += 1
|
||||
|
||||
const child = value()
|
||||
child.key = key.value
|
||||
child.keyStart = key.start
|
||||
child.keyEnd = key.end
|
||||
children.push(child)
|
||||
|
||||
ws()
|
||||
if (src[at] === ',') {
|
||||
at += 1
|
||||
continue
|
||||
}
|
||||
if (src[at] === '}') {
|
||||
at += 1
|
||||
return { type: 'object', children, start, end: at }
|
||||
}
|
||||
return fail('expected a comma or a closing brace')
|
||||
}
|
||||
}
|
||||
|
||||
function array() {
|
||||
const start = at
|
||||
at += 1 // [
|
||||
const children = []
|
||||
ws()
|
||||
|
||||
if (src[at] === ']') {
|
||||
at += 1
|
||||
return { type: 'array', children, start, end: at }
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const child = value()
|
||||
child.index = children.length
|
||||
children.push(child)
|
||||
|
||||
ws()
|
||||
if (src[at] === ',') {
|
||||
at += 1
|
||||
continue
|
||||
}
|
||||
if (src[at] === ']') {
|
||||
at += 1
|
||||
return { type: 'array', children, start, end: at }
|
||||
}
|
||||
return fail('expected a comma or a closing bracket')
|
||||
}
|
||||
}
|
||||
|
||||
const root = value()
|
||||
ws()
|
||||
if (at !== src.length) fail('trailing content')
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/** Splits a config key into words, across camelCase, snake_case, spaces and dots. */
|
||||
function words(key) {
|
||||
return String(key)
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key names a credential. See `SECRET_WORDS`.
|
||||
*
|
||||
* **A field flagged here is not emptied.** D37 decided the raw tier shows real
|
||||
* values — an admin can already read the file over SSH — so the API answers with
|
||||
* the document as it is, the form renders a flagged field masked with a reveal
|
||||
* control, and the flag's load-bearing use is the audit trail, where the values
|
||||
* genuinely never appear.
|
||||
*/
|
||||
function isSecretKey(key) {
|
||||
return words(key).some((w) => SECRET_WORDS.has(w))
|
||||
}
|
||||
|
||||
/** A pointer as a person reads it: `Settings.Rates[0].Wood`. */
|
||||
function pointerPath(pointer) {
|
||||
return pointer
|
||||
.map((step) => (typeof step === 'number' ? `[${step}]` : step))
|
||||
.join('.')
|
||||
.replace(/\.\[/g, '[')
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a scanned tree into the flat description the form is built from.
|
||||
*
|
||||
* **What is NOT here is as deliberate as what is.** There are no descriptions,
|
||||
* no minimums, no maximums and no allowed-value sets, because a config file
|
||||
* carries none: the key name is the entire label. An empty array and a `null`
|
||||
* carry no type at all, so nothing can be inferred for them and they are marked
|
||||
* `advanced` — the raw tier is where a value with no shape gets edited.
|
||||
*
|
||||
* @param {object} root from `scan`
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.maxDepth] past this, a subtree is advanced-only
|
||||
* @param {string[]} [options.locked] top-level keys that may not be edited (D38)
|
||||
*/
|
||||
function describe(root, { maxDepth = 6, locked = [] } = {}) {
|
||||
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
|
||||
const fields = []
|
||||
|
||||
function visit(node, pointer, depth, inheritedSecret, inheritedLock) {
|
||||
const key = pointer.length ? pointer[pointer.length - 1] : ''
|
||||
const secret = inheritedSecret || (typeof key === 'string' && isSecretKey(key))
|
||||
const isLocked =
|
||||
inheritedLock || (pointer.length === 1 && typeof key === 'string' && lockedSet.has(key.toLowerCase()))
|
||||
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
const tooDeep = depth >= maxDepth
|
||||
|
||||
fields.push({
|
||||
pointer: [...pointer],
|
||||
path: pointerPath(pointer),
|
||||
key: typeof key === 'number' ? `[${key}]` : key,
|
||||
type: node.type,
|
||||
depth,
|
||||
count: node.children.length,
|
||||
secret,
|
||||
locked: isLocked,
|
||||
// An empty container has nothing to infer a member's shape from, and a
|
||||
// container past the depth limit has more shape than a form should try
|
||||
// to draw. Both are honest reasons to send somebody to the raw tier.
|
||||
advanced: tooDeep || node.children.length === 0,
|
||||
...(tooDeep ? { reason: 'deeper than the form will draw' } : {}),
|
||||
...(node.children.length === 0 ? { reason: 'empty, so there is no shape to read' } : {}),
|
||||
})
|
||||
|
||||
if (tooDeep) return
|
||||
|
||||
node.children.forEach((child, index) => {
|
||||
visit(child, [...pointer, node.type === 'array' ? index : child.key], depth + 1, secret, isLocked)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fields.push({
|
||||
pointer: [...pointer],
|
||||
path: pointerPath(pointer),
|
||||
key: typeof key === 'number' ? `[${key}]` : key,
|
||||
type: node.type,
|
||||
depth,
|
||||
// A number is reported as its LITERAL as well as its value. The literal is
|
||||
// what the form must round-trip; the value is for display and sorting.
|
||||
...(node.type === 'number' ? { raw: node.raw } : {}),
|
||||
value: node.value,
|
||||
secret,
|
||||
locked: isLocked,
|
||||
// `null` has no type, so there is nothing to render but a raw editor.
|
||||
advanced: node.type === 'null',
|
||||
...(node.type === 'null' ? { reason: 'null carries no type to read' } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
visit(root, [], 0, false, false)
|
||||
return fields
|
||||
}
|
||||
|
||||
/** Finds the node a pointer names, or null. */
|
||||
function resolve(root, pointer) {
|
||||
let node = root
|
||||
|
||||
for (const step of pointer) {
|
||||
if (!node || (node.type !== 'object' && node.type !== 'array')) return null
|
||||
|
||||
if (node.type === 'array') {
|
||||
if (typeof step !== 'number') return null
|
||||
node = node.children[step]
|
||||
} else {
|
||||
node = node.children.find((child) => child.key === step)
|
||||
}
|
||||
|
||||
if (!node) return null
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/** The exact source text a node was read from. */
|
||||
function literalOf(text, node) {
|
||||
return String(text).slice(node.start, node.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one edit into the literal that will be spliced in, or explains why not.
|
||||
*
|
||||
* `raw` is used verbatim for a number — that is the whole mechanism — and is
|
||||
* validated against the JSON grammar first, because verbatim and unvalidated
|
||||
* would be a way to write anything at all into somebody's config file.
|
||||
*/
|
||||
function literalFor(node, edit) {
|
||||
if (node.type === 'number') {
|
||||
const raw = String(edit.raw !== undefined && edit.raw !== null ? edit.raw : edit.value).trim()
|
||||
if (!JSON_NUMBER.test(raw)) return { error: `'${raw}' is not a number` }
|
||||
return { literal: raw }
|
||||
}
|
||||
|
||||
if (node.type === 'string') {
|
||||
if (typeof edit.value !== 'string') return { error: 'expected text' }
|
||||
return { literal: JSON.stringify(edit.value) }
|
||||
}
|
||||
|
||||
if (node.type === 'boolean') {
|
||||
if (typeof edit.value !== 'boolean') return { error: 'expected true or false' }
|
||||
return { literal: edit.value ? 'true' : 'false' }
|
||||
}
|
||||
|
||||
return { error: `a ${node.type} is edited in the raw tier` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a set of edits to a document and returns the new text.
|
||||
*
|
||||
* Spans are spliced from the **end of the document backwards**, so that an
|
||||
* earlier edit never moves a later edit's offsets. Every edit is resolved and
|
||||
* checked before any splice happens: a refusal leaves the caller with the
|
||||
* original text rather than a partly-edited one.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {Array<{pointer: Array<string|number>, value?: any, raw?: string}>} edits
|
||||
* @returns {{ text?: string, changes?: object[], error?: string }}
|
||||
*/
|
||||
function applyEdits(text, edits, { locked = [] } = {}) {
|
||||
let root
|
||||
|
||||
try {
|
||||
root = scan(text)
|
||||
} catch (err) {
|
||||
return { error: `the file on the server is not valid JSON: ${err.message}` }
|
||||
}
|
||||
|
||||
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
|
||||
const staged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const edit of edits || []) {
|
||||
const pointer = Array.isArray(edit.pointer) ? edit.pointer : null
|
||||
if (!pointer || pointer.length === 0) return { error: 'an edit must name a field' }
|
||||
|
||||
const path = pointerPath(pointer)
|
||||
if (seen.has(path)) return { error: `'${path}' is edited twice in one save` }
|
||||
seen.add(path)
|
||||
|
||||
if (typeof pointer[0] === 'string' && lockedSet.has(pointer[0].toLowerCase())) {
|
||||
return { error: `'${path}' cannot be edited from the website` }
|
||||
}
|
||||
|
||||
const node = resolve(root, pointer)
|
||||
if (!node) return { error: `'${path}' is not in this file` }
|
||||
|
||||
const { literal, error } = literalFor(node, edit)
|
||||
if (error) return { error: `'${path}': ${error}` }
|
||||
|
||||
staged.push({
|
||||
path,
|
||||
pointer,
|
||||
start: node.start,
|
||||
end: node.end,
|
||||
from: literalOf(text, node),
|
||||
to: literal,
|
||||
secret: pointer.some((step) => typeof step === 'string' && isSecretKey(step)),
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing to do is not an error, but it must not produce a write either: a
|
||||
// save with no changes would spend a reload — and a reload is the one part of
|
||||
// this feature that can take a plugin down.
|
||||
const changed = staged.filter((s) => s.from !== s.to)
|
||||
if (changed.length === 0) return { text: String(text), changes: [] }
|
||||
|
||||
let out = String(text)
|
||||
|
||||
for (const edit of [...changed].sort((a, b) => b.start - a.start)) {
|
||||
out = out.slice(0, edit.start) + edit.to + out.slice(edit.end)
|
||||
}
|
||||
|
||||
// The result must still be JSON. It always is when the pieces are — this is a
|
||||
// guard against a bug in this file, not against the caller.
|
||||
try {
|
||||
scan(out)
|
||||
} catch (err) {
|
||||
return { error: `the edit produced something that is not JSON: ${err.message}` }
|
||||
}
|
||||
|
||||
return { text: out, changes: changed.map(redactChange) }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the audit trail records for one changed field.
|
||||
*
|
||||
* **A secret's values are never written down.** The raw tier shows real values
|
||||
* to an admin who asks for them, which is a deliberate decision (D37) about a
|
||||
* page somebody has to open — but an activity log is read by more people, for
|
||||
* longer, and usually by somebody who was not there. Those are different
|
||||
* exposures and they get different answers.
|
||||
*/
|
||||
function redactChange(change) {
|
||||
return {
|
||||
path: change.path,
|
||||
from: change.secret ? '***' : change.from,
|
||||
to: change.secret ? '***' : change.to,
|
||||
...(change.secret ? { secret: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
JSON_NUMBER,
|
||||
JsonScanError,
|
||||
scan,
|
||||
describe,
|
||||
resolve,
|
||||
applyEdits,
|
||||
isSecretKey,
|
||||
pointerPath,
|
||||
words,
|
||||
}
|
||||
@@ -19,6 +19,21 @@
|
||||
-- it knows this module registered, because it is the side that knows which
|
||||
-- registrant owned what.
|
||||
|
||||
-- Phase 7b.
|
||||
DROP TABLE IF EXISTS rust_config_writes;
|
||||
|
||||
-- Phase 7. Children before parents: every one of these carries a foreign key
|
||||
-- into `rust_servers`, `users` or `rust_perm_groups`.
|
||||
DROP TABLE IF EXISTS rust_perm_catalogue;
|
||||
DROP TABLE IF EXISTS rust_perm_sync;
|
||||
DROP TABLE IF EXISTS rust_perm_revocations;
|
||||
DROP TABLE IF EXISTS rust_perm_drift;
|
||||
DROP TABLE IF EXISTS rust_perm_pushed;
|
||||
DROP TABLE IF EXISTS rust_perm_grants;
|
||||
DROP TABLE IF EXISTS rust_perm_group_members;
|
||||
DROP TABLE IF EXISTS rust_perm_group_permissions;
|
||||
DROP TABLE IF EXISTS rust_perm_groups;
|
||||
DROP TABLE IF EXISTS rust_account_links;
|
||||
DROP TABLE IF EXISTS rust_ingest_cursor;
|
||||
DROP TABLE IF EXISTS rust_presence;
|
||||
DROP TABLE IF EXISTS rust_events;
|
||||
|
||||
@@ -291,6 +291,315 @@ CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
|
||||
);
|
||||
|
||||
|
||||
-- ── Who owns which Steam account ──────────────────────────────────────────
|
||||
--
|
||||
-- R1's identity link, and the reason it is a table rather than a column on
|
||||
-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed
|
||||
-- by a Steam id, and it outlives every row this module writes about play. A
|
||||
-- column here would be null for the overwhelming majority of players and would
|
||||
-- be deleted by any sweep that pruned inactive ones.
|
||||
--
|
||||
-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that
|
||||
-- call in protocol 2 and it is the truth of the thing: a Steam account is one
|
||||
-- person across every server an operator runs, where stats are per server and
|
||||
-- per wipe. Linking on one server links for the fleet, because there is nothing
|
||||
-- else it could honestly mean.
|
||||
--
|
||||
-- **One Steam id, at most one user** — that is what the primary key buys, and it
|
||||
-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may
|
||||
-- do what in game and phase 13 makes it the thing that hands out loot; both are
|
||||
-- grants against a Steam id, and both assume the question "whose is this?" has
|
||||
-- exactly one answer.
|
||||
--
|
||||
-- The reverse is deliberately NOT constrained: one website user may hold several
|
||||
-- Steam accounts. People have a second account, or a family shares a site login,
|
||||
-- and refusing that would be inventing a rule the game does not have.
|
||||
--
|
||||
-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The
|
||||
-- alternative is a row naming a user id that resolves to nobody, which every
|
||||
-- read would then have to defend against.
|
||||
CREATE TABLE IF NOT EXISTS rust_account_links (
|
||||
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
-- What the player was called in game when they linked. A display name, kept
|
||||
-- so an operator reading the admin panel sees a person rather than a number;
|
||||
-- never used to identify anybody, because a Rust name changes on a whim.
|
||||
name VARCHAR(191) NULL,
|
||||
-- Which server minted the code. Not part of the identity — the link is
|
||||
-- fleet-wide — but an operator asking "where did this come from" has no other
|
||||
-- way to find out, and a support conversation starts there.
|
||||
server_id VARCHAR(64) NULL,
|
||||
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
KEY idx_rust_links_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
|
||||
--
|
||||
-- The website is the author of record for who may do what in game, and the
|
||||
-- framework's own permission store is an ENFORCEMENT CACHE. That is one
|
||||
-- sentence with three consequences, and the tables below are shaped by them:
|
||||
--
|
||||
-- • Every third-party plugin honours a site grant with no adapter, because
|
||||
-- they all already call `UserHasPermission`. Nothing here is read by the
|
||||
-- game directly; it is pushed into the store the game already consults.
|
||||
-- • A wipe stops being a data-loss event. The game forgets and the site does
|
||||
-- not, so the next sync puts it all back.
|
||||
-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
|
||||
-- the site has to be able to tell a grant it made from one somebody typed
|
||||
-- at a console — and that is a fact only the site can hold, because the
|
||||
-- store records who granted a permission nowhere.
|
||||
--
|
||||
-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
|
||||
--
|
||||
-- Not against a Steam id, though a Steam id is what reaches the game. The site
|
||||
-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
|
||||
-- earned them, and an account unlinked from a person takes their privileges
|
||||
-- with it. The Steam ids are resolved from `rust_account_links` at push time,
|
||||
-- so a player who links a second account gets what they hold on both — which is
|
||||
-- the honest reading of "this person may do this".
|
||||
--
|
||||
-- A user with no linked account is authored against perfectly well and simply
|
||||
-- reaches nobody until they link. That is visible on the admin screen rather
|
||||
-- than silent, because a grant that reaches nothing looks identical to a grant
|
||||
-- that worked from every other angle.
|
||||
--
|
||||
-- ── Scope (D29) ───────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Every authored row carries one: a server id, or `*` for the whole fleet. The
|
||||
-- game stores permissions per server (each has its own store), an operator
|
||||
-- running a modded server and a vanilla one will not want one set on both, and
|
||||
-- a single-server community never has to think about it.
|
||||
|
||||
|
||||
-- ── Groups ────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Mirrored into the game as REAL groups (D30) rather than flattened into
|
||||
-- per-player grants. Third-party plugins read group membership, BetterChat's
|
||||
-- group API (R15, phase 17) has something to hang on, and an operator reading
|
||||
-- `oxide.show groups` sees what the website shows.
|
||||
--
|
||||
-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
|
||||
-- not go away: **a player the store has never seen cannot be put in a group**,
|
||||
-- while a direct grant to the same id works immediately. The sync reports those
|
||||
-- members as pending and the membership lands on their first connection.
|
||||
--
|
||||
-- The name is the primary key, fleet-wide, even though the row carries a scope:
|
||||
-- one `vip` on the site is one `vip` in the game, pushed to the servers its
|
||||
-- scope names. Two groups of the same name with different scopes would be two
|
||||
-- definitions of one name in every store that received both.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_groups (
|
||||
name VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
title VARCHAR(120) NOT NULL DEFAULT '',
|
||||
rank INT NOT NULL DEFAULT 0,
|
||||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- What each group carries. A row per permission rather than a list on the group
|
||||
-- for the ordinary reason: "which groups grant kits.vip" is the question an
|
||||
-- operator asks when they are about to remove a plugin, and that is a WHERE
|
||||
-- clause here and a scan of every row in the other shape.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
|
||||
group_name VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (group_name, permission),
|
||||
CONSTRAINT fk_rust_perm_group_permissions_group
|
||||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- Who is in each group — by website user, like every other authored row.
|
||||
--
|
||||
-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
|
||||
-- staff member's account being deleted must not delete the record of what they
|
||||
-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
|
||||
-- The activity log is the audit trail; this column is a convenience beside it.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_group_members (
|
||||
group_name VARCHAR(64) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
added_by INT NULL,
|
||||
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (group_name, user_id),
|
||||
KEY idx_rust_perm_members_user (user_id),
|
||||
CONSTRAINT fk_rust_perm_members_group
|
||||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_rust_perm_members_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Direct grants ─────────────────────────────────────────────────────────
|
||||
--
|
||||
-- A permission held by one person, without a group. It is not a lesser version
|
||||
-- of membership: it is the shape that reaches a player who has never connected
|
||||
-- to that server, which is exactly what an entitlement earned on the website at
|
||||
-- three in the morning has to do (R16).
|
||||
--
|
||||
-- `source` is why this table does not need changing in phase 13. Every later
|
||||
-- author — an event action granting the right to redeem a kit, a lease handing
|
||||
-- out a weekend group — writes a row here with its own source rather than a
|
||||
-- store of its own, so there is one answer to "why does this player have this"
|
||||
-- and one place the push reads.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_grants (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||
note VARCHAR(255) NULL,
|
||||
granted_by INT NULL,
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope),
|
||||
KEY idx_rust_perm_grant_user (user_id),
|
||||
CONSTRAINT fk_rust_perm_grants_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── What this site has actually put in each game ──────────────────────────
|
||||
--
|
||||
-- The site's memory of its own authorship, one row per thing it has confirmed
|
||||
-- into one server's store. It is the table that makes D31 possible at all.
|
||||
--
|
||||
-- Three sets, and every interesting question is the difference between two of
|
||||
-- them:
|
||||
--
|
||||
-- desired − pushed what to apply
|
||||
-- pushed − desired what to RETIRE, because the site put it there and has
|
||||
-- since withdrawn it
|
||||
-- present − desired drift: somebody else put it there
|
||||
--
|
||||
-- Without the middle row a withdrawn grant is indistinguishable from a hand
|
||||
-- edit, and those two have opposite correct answers. Inferring it from absence
|
||||
-- is the mistake this table exists to prevent.
|
||||
--
|
||||
-- It is keyed by Steam id rather than by user, because it records what is in the
|
||||
-- GAME, and the game has never heard of a website account. Unlinking an account
|
||||
-- therefore leaves its row here until the next sync retires it — which is the
|
||||
-- correct behaviour and would be impossible to express keyed the other way.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_pushed (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
-- `grant` | `member` | `group-permission` | `group`
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
-- a Steam id, or a group name
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
-- a permission, a group name, or '' for the existence of a group
|
||||
object VARCHAR(128) NOT NULL,
|
||||
pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_pushed_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Drift ─────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- What a sync found in a server's store that the site did not author, within
|
||||
-- the namespace the site claims. Rows appear and disappear with the report:
|
||||
-- this is the CURRENT difference, not a history of differences, and a hand edit
|
||||
-- that somebody has since removed should stop being on the screen.
|
||||
--
|
||||
-- Nothing here is ever removed from the game by the sync itself. An operator
|
||||
-- typing `oxide.grant` during an incident is drift, not an error, and the two
|
||||
-- answers offered to them — adopt it, or revoke it — are both a person's
|
||||
-- decision.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_drift (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
object VARCHAR(128) NOT NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_drift_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Removing something the site never put there ───────────────────────────
|
||||
--
|
||||
-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
|
||||
-- point of a drift row is that it was never pushed. It cannot go through the
|
||||
-- authored tables either: a foreign grant often names a Steam id that belongs
|
||||
-- to no website account at all, and there is no user to author it against.
|
||||
--
|
||||
-- So a revoke is its own instruction with its own lifetime: queued by a person,
|
||||
-- carried in the next sync's retire list, and deleted once a report says the
|
||||
-- game no longer has it. A server that is offline keeps the instruction until
|
||||
-- it comes back, which is the behaviour an operator expects from a website that
|
||||
-- claims to be the author of record.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_revocations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
object VARCHAR(128) NOT NULL,
|
||||
requested_by INT NULL,
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_revocations_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── The state of the mirror, per server ───────────────────────────────────
|
||||
--
|
||||
-- One row per configured server: whether its store currently matches what the
|
||||
-- site authors, when that was last true, and what the last report said.
|
||||
--
|
||||
-- `dirty` is how everything that should provoke a sync says so without knowing
|
||||
-- anything about syncing: an admin writing a grant, a drift hook firing in the
|
||||
-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
|
||||
-- every other part of the module owns WHETHER.
|
||||
--
|
||||
-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
|
||||
-- that pushed the whole set every tick would work and would also write to six
|
||||
-- game servers every thirty seconds for ever; comparing a hash costs one query
|
||||
-- and skips the round trip when nothing has changed. The periodic audit below
|
||||
-- is what keeps that from being a way to never notice drift.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_sync (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
-- `pending` | `ok` | `failed`
|
||||
state VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||
dirty TINYINT(1) NOT NULL DEFAULT 1,
|
||||
desired_hash VARCHAR(64) NULL,
|
||||
synced_hash VARCHAR(64) NULL,
|
||||
boot_id VARCHAR(64) NULL,
|
||||
wipe_id VARCHAR(48) NULL,
|
||||
last_attempt_at DATETIME NULL,
|
||||
last_ok_at DATETIME NULL,
|
||||
report LONGTEXT NULL,
|
||||
error VARCHAR(191) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_perm_sync_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── What each server's plugins have registered ────────────────────────────
|
||||
--
|
||||
-- The option source the authoring form offers (D33), cached from the live read
|
||||
-- so that opening the form is not six round trips to six game hosts.
|
||||
--
|
||||
-- It is a cache of a fact that changes when an operator loads a plugin, and it
|
||||
-- is refreshed on every sync — which is also why a name that has stopped being
|
||||
-- registered disappears from the form rather than lingering as a choice that
|
||||
-- silently does nothing.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, permission),
|
||||
CONSTRAINT fk_rust_perm_catalogue_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Changes to tables that already shipped ────────────────────────────────
|
||||
--
|
||||
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
||||
@@ -298,3 +607,64 @@ CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
|
||||
-- would reach fresh installs only — which is the worst possible distribution for
|
||||
-- a schema change, because it works everywhere it is tested.
|
||||
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL;
|
||||
|
||||
-- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the
|
||||
-- same fact as when the server last said something — and the pages were reading
|
||||
-- the first as if it were the second, so a server that had been down for three
|
||||
-- days rendered "last reported just now" on every failed poll.
|
||||
--
|
||||
-- They are genuinely two facts and both are wanted: `updated_at` decides whether
|
||||
-- the row is stale (a module that stopped polling must not leave a page claiming
|
||||
-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only
|
||||
-- a successful refresh moves it.
|
||||
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL;
|
||||
|
||||
|
||||
-- ── Configuration written from the site (phase 7b, R18) ───────────────────
|
||||
--
|
||||
-- The audit trail for the most powerful thing this website can do to somebody's
|
||||
-- game host: write a file on it. One row per save attempt, including the ones
|
||||
-- that were refused and the ones the plugin rolled back — a write that did not
|
||||
-- land is exactly the row an operator asking "why is ZoneManager down" needs to
|
||||
-- find.
|
||||
--
|
||||
-- **No file bodies.** `changes` holds the fields that changed and their before
|
||||
-- and after LITERALS, which is what a person reading this wants, and secrets are
|
||||
-- redacted on the way in (`configEdit.redactChange`). D37 lets an admin read a
|
||||
-- credential on the page they opened deliberately; this table is read by more
|
||||
-- people, for longer, and usually by somebody who was not there.
|
||||
--
|
||||
-- The versions bracket the write: `version_before` is what the plugin said the
|
||||
-- file was when it was read, `version_after` what it is now. They are the
|
||||
-- plugin's own hashes, echoed — this module never computes one.
|
||||
CREATE TABLE IF NOT EXISTS rust_config_writes (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
plugin VARCHAR(128) NULL,
|
||||
-- What the admin asked us to reload. NULL is an honest value: a file whose
|
||||
-- plugin is not loaded is written and not reloaded, and saying so is the
|
||||
-- difference between "saved" and "in effect".
|
||||
reload_target VARCHAR(128) NULL,
|
||||
-- `form` or `raw`. Which tier an edit came through changes how it should be
|
||||
-- read: a form edit is type-preserving and narrow, a raw edit replaced the
|
||||
-- whole document.
|
||||
tier VARCHAR(16) NOT NULL DEFAULT 'form',
|
||||
user_id INT NULL,
|
||||
-- `applied` | `rolled-back` | `refused` | `unreachable`
|
||||
outcome VARCHAR(24) NOT NULL,
|
||||
reloaded TINYINT(1) NOT NULL DEFAULT 0,
|
||||
changes LONGTEXT NULL,
|
||||
version_before VARCHAR(64) NULL,
|
||||
version_after VARCHAR(64) NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_config_writes_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
|
||||
-- A deleted account must not delete the record that they changed a setting.
|
||||
-- The row stays and the name goes; the alternative is an audit trail that a
|
||||
-- person can erase by closing their account.
|
||||
CONSTRAINT fk_rust_config_writes_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
KEY idx_rust_config_writes_server (server_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
|
||||
const publicRust = require('./router/public/rust.router')
|
||||
const playerRust = require('./router/player/rust.router')
|
||||
const adminRust = require('./router/admin/rust.router')
|
||||
const usersRust = require('./router/admin/usersRust.router')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -78,6 +79,20 @@ module.exports = function register(ctx, api) {
|
||||
admin: { '/rust': adminRust },
|
||||
})
|
||||
|
||||
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
|
||||
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
|
||||
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
|
||||
// declared before the slot is mounted, so core wins any path conflict — it owns
|
||||
// the user, and this module owns what it can say about one.
|
||||
//
|
||||
// **It is declared twice, in two different places, on purpose.** This call is
|
||||
// the SERVER half and `module.json`'s `extensions` array is held against it by
|
||||
// the loader. The CLIENT half is `registry.registerExtension(ID,
|
||||
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
|
||||
// phase 1 found that the hard way with `site.footer.status`, which is a client
|
||||
// slot and fails the load outright when named there.
|
||||
api.registerExtension('admin.users.detail', usersRust)
|
||||
|
||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||
@@ -92,14 +107,15 @@ module.exports = function register(ctx, api) {
|
||||
|
||||
// Everything else this module will register — the Team provider, the event
|
||||
// triggers and audiences, the engagement seeds, the four event catalogues, the
|
||||
// notification streams, the slash commands and the two extension slots — is
|
||||
// deliberately absent. Each arrives with the phase that has something real to
|
||||
// put in it. A registration with nothing behind it is worse than a missing one:
|
||||
// a declared trigger nothing emits and a declared slot nothing fills are both
|
||||
// surfaces an operator can configure and then wait on.
|
||||
// notification streams and the slash commands — is deliberately absent. Each
|
||||
// arrives with the phase that has something real to put in it. A registration
|
||||
// with nothing behind it is worse than a missing one: a declared trigger
|
||||
// nothing emits and a declared slot nothing fills are both surfaces an operator
|
||||
// can configure and then wait on.
|
||||
|
||||
log.info('registered', {
|
||||
version: require('../module.json').version,
|
||||
routes: 'public:/rust player:/rust admin:/rust',
|
||||
extensions: 'admin.users.detail',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
const core = require('./core')
|
||||
|
||||
const db = require('./model/events/events.db')
|
||||
const links = require('./model/links/links.model')
|
||||
const permissionsDb = require('./model/permissions/permissions.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('ingest')
|
||||
@@ -144,6 +146,51 @@ async function apply(serverId, item) {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
// ── Protocol 3: the one frame that changes something other than a counter ──
|
||||
//
|
||||
// `/unlink` in game severs the site's link, and it is the only way out of a
|
||||
// link on the wrong account: the site REFUSES to move a Steam id another
|
||||
// website account already holds (D23), so without this a player who linked
|
||||
// while signed in as the wrong account would need staff.
|
||||
//
|
||||
// It arrives here rather than through a route because the plugin has nothing
|
||||
// to delete — the site is the author of record and the game holds no link —
|
||||
// so `/unlink` is the game reporting what the player asked for, applied off
|
||||
// the feed like every other frame.
|
||||
//
|
||||
// **The authority is the Steam account itself.** Whoever is connected to the
|
||||
// game as it is who it is, which is a stronger proof of ownership than the
|
||||
// site can obtain any other way, so this is not scoped by website user.
|
||||
case 'account.unlinked':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await links.unlinkFromGame(frame.steamId)
|
||||
break
|
||||
|
||||
// Stored and counted as a sighting, nothing more. The code is deliberately
|
||||
// NOT on this frame — it travels through the player — so there is nothing
|
||||
// here to redeem and no pending state for the site to hold. It exists so an
|
||||
// operator can see linking being used at all.
|
||||
case 'account.link.requested':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
// ── Protocol 4: somebody changed the permission store, and it was not us ──
|
||||
//
|
||||
// The plugin raises this only for writes it did not make itself — its own
|
||||
// sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What
|
||||
// arrives here is therefore a hand edit, a console command, or another
|
||||
// plugin granting something.
|
||||
//
|
||||
// **It is a reason to reconcile, not the reconciliation.** This frame cannot
|
||||
// say whether the change is foreign: only the desired set can, and that
|
||||
// comparison happens in the sync. So the server is marked dirty and the next
|
||||
// tick produces the authoritative answer — which means a hook that stops
|
||||
// firing on a framework upgrade costs latency and nothing else. The audit
|
||||
// interval finds the same drift within fifteen minutes either way.
|
||||
case 'perm.drift':
|
||||
await permissionsDb.markDirty(serverId)
|
||||
break
|
||||
|
||||
default:
|
||||
// Stored, not counted. Moderation frames, the server lifecycle, and
|
||||
// anything a newer protocol sends that this build does not understand.
|
||||
|
||||
79
server/model/config/config.db.js
Normal file
79
server/model/config/config.db.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// ── The SQL half of the configuration audit ───────────────────────────────
|
||||
//
|
||||
// One table, two questions: record what a save did, and show an operator what
|
||||
// has been done to a server lately.
|
||||
//
|
||||
// Nothing here talks to a game. The game half is `sidecarClient`, and the two
|
||||
// are deliberately not mixed: this file is what remains true after the plugin
|
||||
// has been reloaded, rolled back, or lost.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
/**
|
||||
* Records one save attempt — including the ones that never reached a file.
|
||||
*
|
||||
* A refusal is written for the same reason a success is: an operator asking why
|
||||
* a setting is not what they set has to be able to see that somebody tried and
|
||||
* was told no, and a table that only holds successes answers that question with
|
||||
* silence.
|
||||
*/
|
||||
async function recordWrite(row) {
|
||||
await core.query(
|
||||
`INSERT INTO rust_config_writes
|
||||
(server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded,
|
||||
changes, version_before, version_after, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.serverId,
|
||||
row.path,
|
||||
row.plugin || null,
|
||||
row.reloadTarget || null,
|
||||
row.tier || 'form',
|
||||
row.userId || null,
|
||||
row.outcome,
|
||||
row.reloaded ? 1 : 0,
|
||||
row.changes ? JSON.stringify(row.changes) : null,
|
||||
row.versionBefore || null,
|
||||
row.versionAfter || null,
|
||||
row.detail ? String(row.detail).slice(0, 500) : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recent history for one server, newest first.
|
||||
*
|
||||
* `changes` comes back parsed, and a row whose JSON will not parse comes back
|
||||
* with `null` rather than throwing — a corrupt audit row must not be able to
|
||||
* break the page that displays the rest of them.
|
||||
*/
|
||||
async function recentWrites(serverId, limit = 50) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
|
||||
user_id AS userId, outcome, reloaded, changes,
|
||||
version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt
|
||||
FROM rust_config_writes
|
||||
WHERE server_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`,
|
||||
[serverId, Math.max(1, Math.min(Number(limit) || 50, 200))],
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
reloaded: Boolean(row.reloaded),
|
||||
changes: parseChanges(row.changes),
|
||||
}))
|
||||
}
|
||||
|
||||
function parseChanges(raw) {
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordWrite, recentWrites, parseChanges }
|
||||
229
server/model/config/config.model.js
Normal file
229
server/model/config/config.model.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// ── The logic half of configuration-from-the-site ─────────────────────────
|
||||
//
|
||||
// Everything here is about the difference between what a game host reports and
|
||||
// what an admin should be shown. Three jobs:
|
||||
//
|
||||
// 1. **Group a flat file list by plugin**, because one plugin can own several
|
||||
// files and a form that lists 40 paths is not a settings screen.
|
||||
// 2. **Say which file is ours, and which keys inside it are locked** (D38). The
|
||||
// plugin names itself in the catalogue rather than us matching a filename,
|
||||
// so renaming the file cannot quietly unlock the three keys that would cut
|
||||
// the link or split a server's history.
|
||||
// 3. **Decide nothing about paths.** The only process that can say whether a
|
||||
// path resolves inside a configuration directory is the one holding the
|
||||
// directory. This file checks SHAPE, so an obviously malformed request is
|
||||
// refused before it costs a round trip — never as a substitute for the real
|
||||
// check on the host.
|
||||
|
||||
const configEdit = require('../../configEdit')
|
||||
|
||||
/**
|
||||
* Keys in the bridge plugin's own config that the website may not change (D38).
|
||||
*
|
||||
* `Host` and `Port` are the link this edit is travelling over, and `ServerId` is
|
||||
* how every row this module has ever stored is keyed — changing it does not
|
||||
* rename a server, it strands its history and starts a new one under a name
|
||||
* nobody chose deliberately. All three are editable on the host, by a person
|
||||
* who is standing on it.
|
||||
*/
|
||||
const LOCKED_KEYS = ['Host', 'Port', 'ServerId']
|
||||
|
||||
/** What a locked field says for itself, on the screen and in a refusal. */
|
||||
const LOCKED_REASON = {
|
||||
host: 'the website reaches this server through this address',
|
||||
port: 'the website reaches this server through this port',
|
||||
serverid: 'every row this site holds for this server is keyed to this id',
|
||||
}
|
||||
|
||||
/**
|
||||
* A path shaped like something the host could plausibly have listed.
|
||||
*
|
||||
* Deliberately narrow and deliberately **not** the security boundary: no `..`,
|
||||
* nothing absolute, no drive letter, forward slashes, and it ends in `.json`.
|
||||
*/
|
||||
const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/
|
||||
|
||||
function isPlausiblePath(path) {
|
||||
return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes the plugin's catalogue into the screen's shape: plugins, each with its
|
||||
* files, each file saying whether it can be edited and why not.
|
||||
*
|
||||
* A file whose guessed plugin is not loaded is kept and **marked**, not dropped.
|
||||
* An operator whose config for an unloaded plugin vanished from the page would
|
||||
* conclude the bridge cannot see it, which is a different and much more alarming
|
||||
* problem than the true one.
|
||||
*/
|
||||
function shapeCatalogue(catalogue) {
|
||||
if (!catalogue || typeof catalogue !== 'object') return null
|
||||
|
||||
const loaded = Array.isArray(catalogue.plugins) ? catalogue.plugins : []
|
||||
const byName = new Map(loaded.map((p) => [String(p.name).toLowerCase(), p]))
|
||||
const self = catalogue.self ? String(catalogue.self) : null
|
||||
|
||||
const groups = new Map()
|
||||
|
||||
for (const file of Array.isArray(catalogue.files) ? catalogue.files : []) {
|
||||
const plugin = String(file.plugin || 'unknown')
|
||||
const key = plugin.toLowerCase()
|
||||
|
||||
if (!groups.has(key)) {
|
||||
const match = byName.get(key)
|
||||
groups.set(key, {
|
||||
plugin,
|
||||
loaded: Boolean(match),
|
||||
title: match ? match.title : null,
|
||||
version: match ? match.version : null,
|
||||
// The bridge plugin cannot reload itself — the reload would close the
|
||||
// link carrying the answer — so the screen says so up front rather than
|
||||
// offering a button that always refuses.
|
||||
isBridge: self != null && plugin.toLowerCase() === self.toLowerCase(),
|
||||
files: [],
|
||||
})
|
||||
}
|
||||
|
||||
groups.get(key).files.push({
|
||||
path: String(file.path),
|
||||
bytes: Number(file.bytes) || 0,
|
||||
modified: file.modified ? Number(file.modified) : null,
|
||||
editable: file.editable !== false,
|
||||
...(file.reason ? { reason: String(file.reason) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
root: catalogue.root ? String(catalogue.root) : null,
|
||||
self,
|
||||
truncated: Boolean(catalogue.truncated),
|
||||
limits: catalogue.limits || null,
|
||||
plugins: [...groups.values()].sort((a, b) => a.plugin.localeCompare(b.plugin)),
|
||||
loaded: loaded
|
||||
.map((p) => ({ name: String(p.name), title: p.title || null, version: p.version || null }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this file is the bridge's own config, by the name the plugin gave. */
|
||||
function isBridgeConfig(path, self) {
|
||||
if (!self) return false
|
||||
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
|
||||
return plugin.toLowerCase() === String(self).toLowerCase()
|
||||
}
|
||||
|
||||
/** The locked keys for a file: three of them in our own config, none anywhere else. */
|
||||
function lockedKeysFor(path, self) {
|
||||
return isBridgeConfig(path, self) ? LOCKED_KEYS : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one file the host sent into what the form renders.
|
||||
*
|
||||
* The text is passed through untouched. What is added is the READING of it: the
|
||||
* field list, which fields are locked, and which hold something a browser should
|
||||
* mask by default.
|
||||
*/
|
||||
function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
|
||||
if (!file || typeof file.text !== 'string') return null
|
||||
|
||||
const locked = lockedKeysFor(file.path, self)
|
||||
let fields = null
|
||||
let parseError = null
|
||||
|
||||
try {
|
||||
fields = configEdit.describe(configEdit.scan(file.text), { maxDepth, locked })
|
||||
} catch (err) {
|
||||
// A config already broken on disk still opens — in the raw tier, which is
|
||||
// the only thing that can fix it. A page that refused to show a broken file
|
||||
// would send somebody to SSH for the one job this feature exists to do.
|
||||
parseError = err.message
|
||||
}
|
||||
|
||||
return {
|
||||
path: String(file.path),
|
||||
plugin: file.plugin ? String(file.plugin) : null,
|
||||
version: String(file.version),
|
||||
bytes: Number(file.bytes) || 0,
|
||||
modified: file.modified ? Number(file.modified) : null,
|
||||
text: file.text,
|
||||
fields,
|
||||
parseError,
|
||||
locked: locked.map((key) => ({ key, reason: LOCKED_REASON[key.toLowerCase()] || null })),
|
||||
isBridge: isBridgeConfig(file.path, self),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which locked keys differ between two versions of a document.
|
||||
*
|
||||
* The form refuses a locked field by pointer, but the **raw tier submits a whole
|
||||
* document**, and a document can change `Port` without anything resembling an
|
||||
* edit to a field. So the raw tier is checked the only way it can be: by
|
||||
* comparing the literals before and after.
|
||||
*
|
||||
* A file that will not parse is not a way around this — an unparseable document
|
||||
* is refused before it gets here.
|
||||
*/
|
||||
function lockedChanges(before, after, locked) {
|
||||
if (!locked || locked.length === 0) return []
|
||||
|
||||
let a
|
||||
let b
|
||||
|
||||
try {
|
||||
a = configEdit.scan(before)
|
||||
b = configEdit.scan(after)
|
||||
} catch {
|
||||
// Nothing can be compared, so nothing is cleared. The caller refuses.
|
||||
return locked.slice()
|
||||
}
|
||||
|
||||
const literal = (root, key) => {
|
||||
if (root.type !== 'object') return null
|
||||
const node = root.children.find((c) => String(c.key).toLowerCase() === key.toLowerCase())
|
||||
return node ? JSON.stringify(node.value) + ':' + (node.raw || '') : null
|
||||
}
|
||||
|
||||
return locked.filter((key) => literal(a, key) !== literal(b, key))
|
||||
}
|
||||
|
||||
/** Every change a report says landed, as one line per file. */
|
||||
function summariseReport(report) {
|
||||
if (!report || typeof report !== 'object') return null
|
||||
|
||||
const files = Array.isArray(report.files) ? report.files : []
|
||||
|
||||
return {
|
||||
ok: report.ok !== false,
|
||||
reloaded: Boolean(report.reloaded),
|
||||
rolledBack: Boolean(report.rolledBack),
|
||||
reason: report.reason ? String(report.reason) : null,
|
||||
// The plugin only reads this on the failure path, and it is the difference
|
||||
// between "your change was undone" and "your change was undone BECAUSE line
|
||||
// 14 is not valid for that field".
|
||||
log: report.log ? String(report.log).slice(-4000) : null,
|
||||
files: files.map((f) => ({
|
||||
path: String(f.path),
|
||||
version: f.version ? String(f.version) : null,
|
||||
bytes: f.bytes != null ? Number(f.bytes) : null,
|
||||
// Both frameworks merge missing defaults on load and save the file back,
|
||||
// so the file after a successful reload is regularly not the file we
|
||||
// wrote. Saying so keeps an operator from reading it as our bug.
|
||||
rewritten: Boolean(f.rewritten),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOCKED_KEYS,
|
||||
LOCKED_REASON,
|
||||
PATH_SHAPE,
|
||||
isPlausiblePath,
|
||||
shapeCatalogue,
|
||||
shapeFile,
|
||||
isBridgeConfig,
|
||||
lockedKeysFor,
|
||||
lockedChanges,
|
||||
summariseReport,
|
||||
}
|
||||
159
server/model/links/links.db.js
Normal file
159
server/model/links/links.db.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
||||
//
|
||||
// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its
|
||||
// keep). Raw parameterised SQL through `core.query`, placeholders always.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const LINKS = 'rust_account_links'
|
||||
const PLAYERS = 'rust_players'
|
||||
const STATS = 'rust_player_wipe_stats'
|
||||
|
||||
/**
|
||||
* The link for one Steam id, or undefined.
|
||||
*
|
||||
* Joins core's `users` for the username, because every caller that asks "who
|
||||
* owns this?" wants a name rather than an integer — and the one caller that
|
||||
* refuses a re-link has to be able to say *whose* it is.
|
||||
*/
|
||||
async function getBySteamId(steamId) {
|
||||
const rows = await core.query(
|
||||
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
||||
l.linked_at AS linkedAt, u.username
|
||||
FROM ${LINKS} l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
WHERE l.steam_id = ?`,
|
||||
[steamId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Steam account one website user holds, newest first.
|
||||
*
|
||||
* **It joins `rust_players` for the name the game last saw**, and that is not a
|
||||
* convenience. The name on the LINK is what the player was called at the moment
|
||||
* they linked, which is a Rust name and changes on a whim — so a player who has
|
||||
* renamed since sees a name they no longer use, on the one page of the site that
|
||||
* is about who they are. The admin panel already preferred the newer one; this
|
||||
* is the same rule applied where the person themselves is reading.
|
||||
*
|
||||
* A LEFT JOIN, because a player can link an account and never play on it.
|
||||
*/
|
||||
async function listForUser(userId) {
|
||||
return core.query(
|
||||
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
||||
l.linked_at AS linkedAt, p.name AS playerName
|
||||
FROM ${LINKS} l
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
||||
WHERE l.user_id = ?
|
||||
ORDER BY l.linked_at DESC`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a link.
|
||||
*
|
||||
* **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in
|
||||
* SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one
|
||||
* website account to another — which, once phase 7 makes a link a privilege path
|
||||
* and phase 13 makes it an entitlement, is an account takeover performed by
|
||||
* typing a six-character code. The duplicate-key error is the refusal, and the
|
||||
* controller turns it into a sentence.
|
||||
*/
|
||||
async function insert({ steamId, userId, name, serverId }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${LINKS} (steam_id, user_id, name, server_id)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[steamId, userId, name || null, serverId || null],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link the caller owns.
|
||||
*
|
||||
* Scoped by `user_id` in the statement rather than checked before it: a delete
|
||||
* that reads, decides, then writes has a gap between the read and the write, and
|
||||
* this way the ownership test and the deletion are the same operation. Answers
|
||||
* how many rows went, so a caller can tell "removed" from "was not yours".
|
||||
*/
|
||||
async function removeOwned(steamId, userId) {
|
||||
const result = await core.query(
|
||||
`DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`,
|
||||
[steamId, userId],
|
||||
)
|
||||
return Number(result && result.affectedRows) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link whoever holds it — the in-game `/unlink` path, and the staff
|
||||
* unlink on the `admin.users.detail` panel (D25).
|
||||
*
|
||||
* Unscoped by user on purpose: neither caller is the link's owner and both have
|
||||
* already established their authority another way. In game the authority is the
|
||||
* Steam account itself — whoever is connected as it is who it is; on the admin
|
||||
* panel it is the tier gate. Which is why the admin caller writes an
|
||||
* `activity.log` entry naming the operator and this does not: it cannot tell the
|
||||
* two apart, and a log line that guessed would be worse than none.
|
||||
*/
|
||||
async function removeBySteamId(steamId) {
|
||||
const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId])
|
||||
return Number(result && result.affectedRows) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link one user holds, enriched with what this module knows about that
|
||||
* player — for the `admin.users.detail` panel.
|
||||
*
|
||||
* A LEFT JOIN, because a player can link an account and never play on it. An
|
||||
* operator looking at that user should see the link, not an empty panel.
|
||||
*/
|
||||
async function listForUserWithPlayer(userId) {
|
||||
return core.query(
|
||||
`SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt,
|
||||
p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen
|
||||
FROM ${LINKS} l
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
||||
WHERE l.user_id = ?
|
||||
ORDER BY l.linked_at DESC`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-server all-time totals for one Steam id.
|
||||
*
|
||||
* The same rows the public leaderboard sums, grouped by server instead of
|
||||
* filtered to one — so an operator sees a player across the fleet in one read.
|
||||
* All-time, deliberately: an admin looking at a user wants their history, not
|
||||
* this week's.
|
||||
*/
|
||||
async function statsForSteamId(steamId) {
|
||||
return core.query(
|
||||
`SELECT s.server_id AS serverId, srv.name AS serverName,
|
||||
SUM(s.kills) AS kills,
|
||||
SUM(s.deaths) AS deaths,
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
MAX(s.last_seen) AS lastSeen,
|
||||
COUNT(DISTINCT s.wipe_id) AS wipes
|
||||
FROM ${STATS} s
|
||||
LEFT JOIN rust_servers srv ON srv.id = s.server_id
|
||||
WHERE s.steam_id = ?
|
||||
GROUP BY s.server_id, srv.name
|
||||
ORDER BY SUM(s.playtime_sec) DESC`,
|
||||
[steamId],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBySteamId,
|
||||
listForUser,
|
||||
listForUserWithPlayer,
|
||||
insert,
|
||||
removeOwned,
|
||||
removeBySteamId,
|
||||
statsForSteamId,
|
||||
}
|
||||
258
server/model/links/links.model.js
Normal file
258
server/model/links/links.model.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// ── Who owns which Steam account ──────────────────────────────────────────
|
||||
//
|
||||
// R1's identity link, site-side. The flow it sits in the middle of:
|
||||
//
|
||||
// 1. In game, a player types `/link`. The plugin mints a one-time code, tells
|
||||
// them privately, and holds it in memory for five minutes.
|
||||
// 2. On the website, the player types that code. This module asks the sidecar,
|
||||
// which asks the plugin, which answers with the Steam id the code belongs
|
||||
// to and drops it.
|
||||
// 3. This file records the result.
|
||||
//
|
||||
// **The site is the author of record and the game holds nothing.** That is the
|
||||
// one real difference from the UO bridge, which writes a tag onto the game
|
||||
// account: there is no equivalent per-account store in Rust that survives a wipe,
|
||||
// and phase 7 needs the site to be authoritative anyway — it pushes permissions
|
||||
// INTO the game keyed by Steam id. A copy in the game would be a second thing to
|
||||
// reconcile every wipe, for no question it could answer better.
|
||||
|
||||
const core = require('../../core')
|
||||
const db = require('./links.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const sidecar = require('../../sidecarClient')
|
||||
|
||||
const log = core.logger('links')
|
||||
|
||||
/** What a link looks like to any caller. Never carries a raw code. */
|
||||
function shape(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
steamId: row.steamId,
|
||||
name: row.name || null,
|
||||
serverId: row.serverId || null,
|
||||
linkedAt: row.linkedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam accounts one website user holds.
|
||||
*
|
||||
* The name is the one the GAME last saw, falling back to the one recorded when
|
||||
* they linked — the rule the admin panel already used, applied on the page the
|
||||
* player themselves reads. A browser walk found the two disagreeing: staff saw
|
||||
* `Wanderer` and the player saw `Wanderer-old`, for the same person on the same
|
||||
* site.
|
||||
*/
|
||||
async function listForUser(userId) {
|
||||
return (await db.listForUser(userId)).map((row) => ({
|
||||
...shape(row),
|
||||
name: row.playerName || row.name || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/** True when this user holds this Steam id. The ownership gate every player read uses. */
|
||||
async function owns(steamId, userId) {
|
||||
const row = await db.getBySteamId(steamId)
|
||||
return Boolean(row && Number(row.userId) === Number(userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a code against one server, and record the link.
|
||||
*
|
||||
* Answers a discriminated result rather than throwing, because every outcome
|
||||
* here is a sentence somebody has to read:
|
||||
*
|
||||
* `{ ok: true, link }` — linked
|
||||
* `{ ok: false, reason: 'rejected' }`— the game says that code is not good
|
||||
* `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id
|
||||
* `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer
|
||||
*
|
||||
* **`rejected` deliberately collapses "unknown" and "expired".** The plugin
|
||||
* distinguishes them and an operator reading its log can too; a stranger typing
|
||||
* codes must not learn which of the two they hit, because that is the difference
|
||||
* between "keep guessing" and "guess faster".
|
||||
*/
|
||||
async function confirmOne({ server, code, userId }) {
|
||||
const result = await sidecar.confirmLink(server, code)
|
||||
|
||||
// The transport failed: the sidecar is unreachable, the game is not connected,
|
||||
// or the reply never came. None of those is a verdict on the code, so the
|
||||
// player is told to try again rather than that their code is wrong.
|
||||
if (!result.ok) {
|
||||
log.warn('link confirm did not reach the game', { server: server.id, status: result.status })
|
||||
return { ok: false, reason: 'offline' }
|
||||
}
|
||||
|
||||
const frame = result.data || {}
|
||||
|
||||
// The plugin's own refusal. `frame.reason` is `unknown`, `expired` or
|
||||
// `malformed`; it is logged and not surfaced (see the doc above).
|
||||
if (frame.kind !== 'link.ok' || !frame.steamId) {
|
||||
log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' })
|
||||
return { ok: false, reason: 'rejected' }
|
||||
}
|
||||
|
||||
const steamId = String(frame.steamId)
|
||||
const held = await db.getBySteamId(steamId)
|
||||
|
||||
// D23: refuse, and say whose it is. A move would transfer every permission and
|
||||
// entitlement phases 7 and 13 hang off this link, on a code anybody in game
|
||||
// could have run — and the player's way out is `/unlink` in game, which they
|
||||
// can reach from the machine they are sitting at.
|
||||
if (held) {
|
||||
if (Number(held.userId) === Number(userId)) {
|
||||
// Already theirs. Not an error: a player who pressed the button twice, or
|
||||
// one whose code was confirmed on a request that then timed out.
|
||||
return { ok: true, link: shape(held), already: true }
|
||||
}
|
||||
return { ok: false, reason: 'taken', username: held.username }
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert({
|
||||
steamId,
|
||||
userId,
|
||||
name: frame.name || null,
|
||||
serverId: server.id,
|
||||
})
|
||||
} catch (err) {
|
||||
// The race the PRIMARY KEY exists for: two confirmations of the same Steam
|
||||
// id, interleaved between the check above and this write. The key refuses the
|
||||
// second and it becomes the same refusal, rather than a 500.
|
||||
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
||||
const now = await db.getBySteamId(steamId)
|
||||
if (now && Number(now.userId) === Number(userId)) {
|
||||
return { ok: true, link: shape(now), already: true }
|
||||
}
|
||||
return { ok: false, reason: 'taken', username: now && now.username }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const link = shape(await db.getBySteamId(steamId))
|
||||
log.info('steam account linked', { steamId, userId, server: server.id })
|
||||
return { ok: true, link }
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a code against the fleet (D24).
|
||||
*
|
||||
* **A code is minted by ONE server and the player types six characters into a
|
||||
* browser**, so the site cannot know which server it came from — nothing in the
|
||||
* code says, and asking the player to pick would make a wrong guess
|
||||
* indistinguishable from a wrong code, which is the one refusal that must not be
|
||||
* ambiguous. So every enabled server is asked in turn and the first `link.ok`
|
||||
* wins. The others answer `unknown` and nothing happens there: a code is only
|
||||
* spent at the server that actually holds it.
|
||||
*
|
||||
* The loop stops early on `taken`, because that is a verdict about the Steam id
|
||||
* rather than about this server — asking the rest of the fleet would produce the
|
||||
* same answer more slowly.
|
||||
*
|
||||
* **"Every reachable server refused" is not the same answer as "a server was
|
||||
* unreachable"**, and collapsing them is how a player who linked on the one
|
||||
* server that is down gets told their code is wrong. `unsure` is that case, and
|
||||
* the sentence it earns says to try again rather than to run `/link` again.
|
||||
*/
|
||||
async function redeem({ code, userId }) {
|
||||
const fleet = await servers.listForPolling()
|
||||
|
||||
if (fleet.length === 0) return { ok: false, reason: 'no-servers' }
|
||||
|
||||
let refused = 0
|
||||
let unreachable = 0
|
||||
|
||||
for (const server of fleet) {
|
||||
// Sequential, deliberately. In parallel every server would be asked even
|
||||
// after one had already answered, and a code spent on the right server would
|
||||
// still be travelling to five others — for a fleet of six and a five-minute
|
||||
// TTL, there is nothing to win by racing them.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await confirmOne({ server, code, userId })
|
||||
|
||||
if (result.ok || result.reason === 'taken') return result
|
||||
|
||||
if (result.reason === 'offline') unreachable += 1
|
||||
else refused += 1
|
||||
}
|
||||
|
||||
if (refused === 0) return { ok: false, reason: 'offline' }
|
||||
if (unreachable > 0) return { ok: false, reason: 'unsure' }
|
||||
|
||||
return { ok: false, reason: 'rejected' }
|
||||
}
|
||||
|
||||
/** Remove a link the caller owns. False when they did not hold it. */
|
||||
async function unlinkOwned(steamId, userId) {
|
||||
return (await db.removeOwned(steamId, userId)) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link whoever holds it.
|
||||
*
|
||||
* Two callers, both of which have already established their authority and
|
||||
* neither of which is the link's owner: ingest applying an in-game `/unlink`
|
||||
* (the authority is the Steam account — whoever is connected as it is who it
|
||||
* is), and a staff unlink from the `admin.users.detail` panel (D25).
|
||||
*
|
||||
* It logs nothing about who asked, because the two callers record that
|
||||
* differently: the admin one writes an `activity.log` entry naming the operator,
|
||||
* and the game one has no operator to name.
|
||||
*/
|
||||
async function unlinkAnyOwner(steamId) {
|
||||
return (await db.removeBySteamId(steamId)) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link because the player asked in game.
|
||||
*
|
||||
* Called from ingest, off an `account.unlinked` event.
|
||||
*/
|
||||
async function unlinkFromGame(steamId) {
|
||||
const removed = await unlinkAnyOwner(steamId)
|
||||
if (removed) log.info('steam account unlinked in game', { steamId })
|
||||
return 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)
|
||||
|
||||
return Promise.all(
|
||||
links.map(async (row) => ({
|
||||
steamId: row.steamId,
|
||||
// The name on the LINK is what they were called when they linked; the one
|
||||
// on `rust_players` is what the game last saw. They differ the moment
|
||||
// somebody renames, and the newer one is the useful one to show.
|
||||
name: row.playerName || row.name || null,
|
||||
linkedName: row.name || null,
|
||||
serverId: row.serverId || null,
|
||||
linkedAt: row.linkedAt,
|
||||
firstSeen: row.firstSeen || null,
|
||||
lastSeen: row.lastSeen || null,
|
||||
servers: (await db.statsForSteamId(row.steamId)).map((s) => ({
|
||||
serverId: s.serverId,
|
||||
serverName: s.serverName || s.serverId,
|
||||
kills: Number(s.kills) || 0,
|
||||
deaths: Number(s.deaths) || 0,
|
||||
npcKills: Number(s.npcKills) || 0,
|
||||
structures: Number(s.structures) || 0,
|
||||
playtimeSec: Number(s.playtimeSec) || 0,
|
||||
wipes: Number(s.wipes) || 0,
|
||||
lastSeen: s.lastSeen || null,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shape,
|
||||
listForUser,
|
||||
owns,
|
||||
confirmOne,
|
||||
redeem,
|
||||
unlinkOwned,
|
||||
unlinkAnyOwner,
|
||||
unlinkFromGame,
|
||||
forAdmin,
|
||||
}
|
||||
459
server/model/permissions/permissions.db.js
Normal file
459
server/model/permissions/permissions.db.js
Normal file
@@ -0,0 +1,459 @@
|
||||
// ── SQL for the permission mirror, and nothing else ───────────────────────
|
||||
//
|
||||
// The tables this file reads are described at length in `db/schema.sql`; what
|
||||
// matters here is which of them is authoritative for what, because four of the
|
||||
// eight look similar and answer completely different questions:
|
||||
//
|
||||
// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`,
|
||||
// `rust_perm_grants` — what an operator (and later an event) says
|
||||
// should be true. Keyed by WEBSITE USER (D28).
|
||||
// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's
|
||||
// store. Keyed by STEAM ID, because it records what is in the game
|
||||
// and the game has never heard of a website account.
|
||||
// FOUND `rust_perm_drift` — what a sync found that the site did not
|
||||
// author. Replaced whole by each report: it is the current
|
||||
// difference, not a history of differences.
|
||||
// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put
|
||||
// it there. The only way to act on drift, since a foreign grant
|
||||
// often names a Steam id no website account holds.
|
||||
//
|
||||
// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js`
|
||||
// here. Bulk writes are batched into one statement with a generated placeholder
|
||||
// list rather than looped, because a fleet-wide sync writes hundreds of rows and
|
||||
// a round trip each is how a boot tick becomes a second long.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const GROUPS = 'rust_perm_groups'
|
||||
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
|
||||
const GROUP_MEMBERS = 'rust_perm_group_members'
|
||||
const GRANTS = 'rust_perm_grants'
|
||||
const PUSHED = 'rust_perm_pushed'
|
||||
const DRIFT = 'rust_perm_drift'
|
||||
const REVOCATIONS = 'rust_perm_revocations'
|
||||
const SYNC = 'rust_perm_sync'
|
||||
const CATALOGUE = 'rust_perm_catalogue'
|
||||
const LINKS = 'rust_account_links'
|
||||
const SERVERS = 'rust_servers'
|
||||
|
||||
/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
|
||||
function placeholders(rows, width) {
|
||||
return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
|
||||
}
|
||||
|
||||
// ---- the authored set ----
|
||||
|
||||
async function listGroups() {
|
||||
return core.query(
|
||||
`SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM ${GROUPS}
|
||||
ORDER BY \`rank\` DESC, name ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getGroup(name) {
|
||||
const rows = await core.query(
|
||||
`SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`,
|
||||
[name],
|
||||
)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update one group.
|
||||
*
|
||||
* `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the
|
||||
* same screen is not a race worth losing a title over, and the row's identity is
|
||||
* its name either way.
|
||||
*/
|
||||
async function upsertGroup({ name, title, rank, scope }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${GROUPS} (name, title, \`rank\`, scope)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`),
|
||||
scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`,
|
||||
[name, title, rank, scope],
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteGroup(name) {
|
||||
const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name])
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
async function listGroupPermissions() {
|
||||
return core.query(
|
||||
`SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace a group's permission list whole. The form edits a list, so the write is a list. */
|
||||
async function setGroupPermissions(name, permissions) {
|
||||
await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name])
|
||||
|
||||
if (!permissions.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission)
|
||||
VALUES ${placeholders(permissions, 2)}`,
|
||||
permissions.flatMap((permission) => [name, permission]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every membership, with the member's Steam accounts joined on.
|
||||
*
|
||||
* One query rather than a membership read plus a link read per member: the admin
|
||||
* screen renders both together and the push needs both together, and a fleet's
|
||||
* worth of members is one round trip either way.
|
||||
*/
|
||||
async function listGroupMembers() {
|
||||
return core.query(
|
||||
`SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt,
|
||||
u.username, l.steam_id AS steamId, p.name AS playerName
|
||||
FROM ${GROUP_MEMBERS} m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
LEFT JOIN ${LINKS} l ON l.user_id = m.user_id
|
||||
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
||||
ORDER BY m.group_name ASC, u.username ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function addGroupMember(groupName, userId, addedBy) {
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`,
|
||||
[groupName, userId, addedBy],
|
||||
)
|
||||
}
|
||||
|
||||
async function removeGroupMember(groupName, userId) {
|
||||
const result = await core.query(
|
||||
`DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`,
|
||||
[groupName, userId],
|
||||
)
|
||||
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Every direct grant, with the holder's accounts joined on.
|
||||
*
|
||||
* `username` is on the row because a grant with no linked Steam account still
|
||||
* has to be listable and nameable — that state is the one the admin screen most
|
||||
* needs to show, since it looks exactly like a working grant from every other
|
||||
* angle and reaches nobody.
|
||||
*/
|
||||
async function listGrants({ userId = null } = {}) {
|
||||
return core.query(
|
||||
`SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
|
||||
g.granted_at AS grantedAt, u.username,
|
||||
l.steam_id AS steamId, p.name AS playerName
|
||||
FROM ${GRANTS} g
|
||||
JOIN users u ON u.id = g.user_id
|
||||
LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
|
||||
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
||||
${userId === null ? '' : 'WHERE g.user_id = ?'}
|
||||
ORDER BY u.username ASC, g.permission ASC`,
|
||||
userId === null ? [] : [userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function getGrant(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a grant, or leave the one that is already there alone.
|
||||
*
|
||||
* `INSERT IGNORE` against the unique key, and the return says which happened —
|
||||
* the controller needs to tell "granted" from "they already had it" to write an
|
||||
* honest activity row.
|
||||
*/
|
||||
async function insertGrant({ userId, permission, scope, source, note, grantedBy }) {
|
||||
const result = await core.query(
|
||||
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, permission, scope, source, note, grantedBy],
|
||||
)
|
||||
|
||||
return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId }
|
||||
}
|
||||
|
||||
async function deleteGrant(id) {
|
||||
const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One website account by name, for the authoring form.
|
||||
*
|
||||
* A form that made an operator type a numeric user id would be a form nobody
|
||||
* could use, and the alternative — calling core's own admin user search from the
|
||||
* client — would bind this module to the shape of a response the contract does
|
||||
* not cover. Reading the `users` table is already what every join in this file
|
||||
* does.
|
||||
*
|
||||
* Case-insensitive because the column's collation is: core stores usernames in a
|
||||
* `_ci` collation and an exact-case lookup would refuse a name the site itself
|
||||
* considers the same one.
|
||||
*/
|
||||
async function findUserByUsername(username) {
|
||||
const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Which website user holds which Steam account. The join that turns an authored row into a push. */
|
||||
async function listLinks() {
|
||||
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
|
||||
}
|
||||
|
||||
// ---- what is actually out there ----
|
||||
|
||||
async function listPushed(serverId) {
|
||||
return core.query(
|
||||
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function addPushed(serverId, rows) {
|
||||
if (!rows.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
|
||||
VALUES ${placeholders(rows, 4)}`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||
)
|
||||
}
|
||||
|
||||
async function removePushed(serverId, rows) {
|
||||
for (const row of rows) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await core.query(
|
||||
`DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
|
||||
[serverId, row.kind, row.subject, row.object],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one server's drift list with what the latest report found.
|
||||
*
|
||||
* Whole, rather than merged, and `first_seen` survives through the
|
||||
* `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still
|
||||
* answerable while "somebody has since undone it" removes the row.
|
||||
*/
|
||||
async function replaceDrift(serverId, rows) {
|
||||
if (!rows.length) {
|
||||
await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId])
|
||||
return
|
||||
}
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
|
||||
VALUES ${placeholders(rows, 4)}
|
||||
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||
)
|
||||
|
||||
// Anything this report did NOT name is gone from the game, so it goes from
|
||||
// here. Named explicitly rather than swept by timestamp: two syncs a second
|
||||
// apart would make a timestamp window either delete live rows or keep dead
|
||||
// ones, depending on the clock.
|
||||
await core.query(
|
||||
`DELETE FROM ${DRIFT}
|
||||
WHERE server_id = ?
|
||||
AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
|
||||
[serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
|
||||
)
|
||||
}
|
||||
|
||||
async function listDrift() {
|
||||
return core.query(
|
||||
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
|
||||
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
|
||||
l.user_id AS userId, u.username, p.name AS playerName
|
||||
FROM ${DRIFT} d
|
||||
LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN rust_players p ON p.steam_id = d.subject
|
||||
ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getDrift(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function deleteDrift(id) {
|
||||
await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
|
||||
}
|
||||
|
||||
async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[serverId, kind, subject, object, requestedBy],
|
||||
)
|
||||
}
|
||||
|
||||
async function listRevocations(serverId) {
|
||||
return core.query(
|
||||
`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteRevocations(ids) {
|
||||
if (!ids.length) return
|
||||
|
||||
await core.query(
|
||||
`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the state of the mirror ----
|
||||
|
||||
/**
|
||||
* One sync row per configured server, created on demand.
|
||||
*
|
||||
* A server added today has no row and must not therefore be skipped for ever, so
|
||||
* the read inserts what is missing rather than the writer remembering to.
|
||||
*/
|
||||
async function ensureSyncRows() {
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function listSync() {
|
||||
return core.query(
|
||||
`SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash,
|
||||
s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId,
|
||||
s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt,
|
||||
s.report, s.error
|
||||
FROM ${SYNC} s
|
||||
ORDER BY s.server_id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark servers as needing a sync.
|
||||
*
|
||||
* `scope` is a server id or `*`; a fleet-wide change dirties every row, which is
|
||||
* right: the set each server should hold has changed even if only one of them
|
||||
* will notice a difference.
|
||||
*/
|
||||
async function markDirty(scope) {
|
||||
if (!scope || scope === '*') {
|
||||
await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
|
||||
return
|
||||
}
|
||||
|
||||
await core.query(
|
||||
`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`,
|
||||
[scope],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the outcome of one attempt.
|
||||
*
|
||||
* **`dirty` is cleared unconditionally, and that is safe because it is an
|
||||
* optimisation rather than the truth.** Something may well have changed the
|
||||
* authored set while this sync was in flight, and clearing the flag would then
|
||||
* lose that change — except that the loop's real condition is
|
||||
* `desired_hash != synced_hash`, recomputed from the tables on every tick. The
|
||||
* flag only saves a hash comparison; the hash is what cannot be wrong.
|
||||
*
|
||||
* `last_ok_at` moves only on success, and it is passed rather than composed into
|
||||
* the SQL so the statement is the same string every time.
|
||||
*/
|
||||
async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
|
||||
const okAt = state === 'ok' ? new Date() : null
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
|
||||
last_attempt_at, last_ok_at, report, error, updated_at)
|
||||
VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
|
||||
desired_hash = VALUES(desired_hash),
|
||||
synced_hash = VALUES(synced_hash),
|
||||
boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
|
||||
last_attempt_at = NOW(),
|
||||
last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
|
||||
report = VALUES(report), error = VALUES(error),
|
||||
updated_at = NOW()`,
|
||||
[serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the option source ----
|
||||
|
||||
async function putCatalogue(serverId, permissions) {
|
||||
await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
|
||||
|
||||
if (!permissions.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${CATALOGUE} (server_id, permission)
|
||||
VALUES ${placeholders(permissions, 2)}`,
|
||||
permissions.flatMap((permission) => [serverId, permission]),
|
||||
)
|
||||
}
|
||||
|
||||
async function listCatalogue() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GROUPS,
|
||||
GRANTS,
|
||||
PUSHED,
|
||||
DRIFT,
|
||||
listGroups,
|
||||
getGroup,
|
||||
upsertGroup,
|
||||
deleteGroup,
|
||||
listGroupPermissions,
|
||||
setGroupPermissions,
|
||||
listGroupMembers,
|
||||
addGroupMember,
|
||||
removeGroupMember,
|
||||
listGrants,
|
||||
getGrant,
|
||||
insertGrant,
|
||||
deleteGrant,
|
||||
findUserByUsername,
|
||||
listLinks,
|
||||
listPushed,
|
||||
addPushed,
|
||||
removePushed,
|
||||
replaceDrift,
|
||||
listDrift,
|
||||
getDrift,
|
||||
deleteDrift,
|
||||
queueRevocation,
|
||||
listRevocations,
|
||||
deleteRevocations,
|
||||
ensureSyncRows,
|
||||
listSync,
|
||||
markDirty,
|
||||
putSyncResult,
|
||||
putCatalogue,
|
||||
listCatalogue,
|
||||
}
|
||||
356
server/model/permissions/permissions.model.js
Normal file
356
server/model/permissions/permissions.model.js
Normal file
@@ -0,0 +1,356 @@
|
||||
// ── The authored set, and what it means for one server ────────────────────
|
||||
//
|
||||
// This file turns "what an operator wrote on the website" into "what one game
|
||||
// server's store should contain", which is where four of phase 7's decisions
|
||||
// actually live:
|
||||
//
|
||||
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
|
||||
// id they have linked, here, at the moment of the push.
|
||||
// D29 every authored row carries a scope — one server, or `*` for the fleet —
|
||||
// and a server sees only what names it.
|
||||
// D30 groups travel as groups. Membership is a separate wire fact from the
|
||||
// permissions the group carries, because the game stores them separately
|
||||
// and one of the two can fail on its own (§12.2 rule 4).
|
||||
// D31 the difference between the desired set and what this site has already
|
||||
// pushed is what gets retired. Anything else in the store is drift, and
|
||||
// drift is reported rather than undone.
|
||||
//
|
||||
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
|
||||
// usual one and earns its keep twice over here: the whole of the interesting
|
||||
// logic is a pure function of four tables, so it is tested without a game, a
|
||||
// sidecar, or a database.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const db = require('./permissions.db')
|
||||
|
||||
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
|
||||
const FLEET = '*'
|
||||
|
||||
/**
|
||||
* Permission and group names, as both frameworks store them.
|
||||
*
|
||||
* Lowercased on the way in, because the store lowers them and a site that did
|
||||
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
|
||||
* grant as drift for ever.
|
||||
*/
|
||||
function normaliseName(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** Whether a scope reaches a server. */
|
||||
function inScope(scope, serverId) {
|
||||
return scope === FLEET || scope === serverId
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the authoring screen renders, in one read.
|
||||
*
|
||||
* Assembled here rather than in SQL because the shape is a tree — a group with
|
||||
* its permissions and its members — and the alternative is either four round
|
||||
* trips per group or one join that repeats every group row once per member.
|
||||
*/
|
||||
async function overview() {
|
||||
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
|
||||
db.listGroups(),
|
||||
db.listGroupPermissions(),
|
||||
db.listGroupMembers(),
|
||||
db.listGrants(),
|
||||
db.listSync(),
|
||||
db.listDrift(),
|
||||
db.listCatalogue(),
|
||||
])
|
||||
|
||||
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
|
||||
|
||||
for (const row of groupPermissions) {
|
||||
const group = byGroup.get(row.groupName)
|
||||
if (group) group.permissions.push(row.permission)
|
||||
}
|
||||
|
||||
// A member with two linked Steam accounts arrives as two rows from the join,
|
||||
// and is one person on the screen — holding BOTH accounts, not the first one
|
||||
// the join happened to return. The screen needs all of them: a membership is
|
||||
// pushed per account, and it can be waiting on one while it landed on another.
|
||||
const memberByKey = new Map()
|
||||
|
||||
for (const row of members) {
|
||||
const group = byGroup.get(row.groupName)
|
||||
if (!group) continue
|
||||
|
||||
const key = `${row.groupName}:${row.userId}`
|
||||
let member = memberByKey.get(key)
|
||||
|
||||
if (!member) {
|
||||
member = {
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
accounts: [],
|
||||
addedAt: row.addedAt,
|
||||
}
|
||||
memberByKey.set(key, member)
|
||||
group.members.push(member)
|
||||
}
|
||||
|
||||
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
|
||||
}
|
||||
|
||||
return {
|
||||
groups: [...byGroup.values()],
|
||||
grants: collapseGrants(grants),
|
||||
servers: sync.map(shapeSync),
|
||||
drift,
|
||||
catalogue: catalogueByPermission(catalogue),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per grant, not one per linked account.
|
||||
*
|
||||
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
|
||||
* what the push wants and the opposite of what a screen wants.
|
||||
*/
|
||||
function collapseGrants(rows) {
|
||||
const byId = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = byId.get(row.id)
|
||||
|
||||
if (!existing) {
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
permission: row.permission,
|
||||
scope: row.scope,
|
||||
source: row.source,
|
||||
note: row.note,
|
||||
grantedAt: row.grantedAt,
|
||||
accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [],
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null })
|
||||
}
|
||||
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* The sync row as a client reads it.
|
||||
*
|
||||
* `report` is stored as the JSON the game sent and parsed here rather than on the
|
||||
* way in, so a report this build cannot read is a rendering problem on one
|
||||
* screen instead of a write that failed.
|
||||
*/
|
||||
function shapeSync(row) {
|
||||
let report = null
|
||||
|
||||
if (row.report) {
|
||||
try {
|
||||
report = JSON.parse(row.report)
|
||||
} catch {
|
||||
report = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
state: row.state,
|
||||
dirty: Boolean(row.dirty),
|
||||
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
||||
lastAttemptAt: row.lastAttemptAt,
|
||||
lastOkAt: row.lastOkAt,
|
||||
error: row.error || null,
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
/** Which servers know each permission name — the form's option source, and its warning label. */
|
||||
function catalogueByPermission(rows) {
|
||||
const byPermission = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
|
||||
byPermission.get(row.permission).push(row.serverId)
|
||||
}
|
||||
|
||||
return [...byPermission.entries()]
|
||||
.map(([permission, servers]) => ({ permission, servers }))
|
||||
.sort((a, b) => a.permission.localeCompare(b.permission))
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole authored set, read once, in the shape the per-server build wants.
|
||||
*
|
||||
* Read once per sync tick rather than once per server: six servers is six
|
||||
* different answers derived from one set of tables, and re-reading them per
|
||||
* server is six times the queries for the same rows.
|
||||
*/
|
||||
async function readAuthored() {
|
||||
const [groups, groupPermissions, members, grants, links] = await Promise.all([
|
||||
db.listGroups(),
|
||||
db.listGroupPermissions(),
|
||||
db.listGroupMembers(),
|
||||
db.listGrants(),
|
||||
db.listLinks(),
|
||||
])
|
||||
|
||||
const steamIdsByUser = new Map()
|
||||
|
||||
for (const link of links) {
|
||||
if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, [])
|
||||
steamIdsByUser.get(link.userId).push(link.steamId)
|
||||
}
|
||||
|
||||
return { groups, groupPermissions, members, grants, steamIdsByUser }
|
||||
}
|
||||
|
||||
/**
|
||||
* What one server's store should contain, and the rows that say so.
|
||||
*
|
||||
* Returns three things the caller needs together and must not compute twice:
|
||||
*
|
||||
* `payload` what goes on the wire
|
||||
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
|
||||
* `hash` a stable digest of `rows`, which is how the loop knows nothing
|
||||
* has changed without asking a game server
|
||||
*
|
||||
* **A user with no linked Steam account contributes nothing and is not an
|
||||
* error.** They are authored against perfectly well and reach nobody until they
|
||||
* link — which the admin screen says out loud, because a grant that reaches
|
||||
* nothing looks exactly like one that worked.
|
||||
*/
|
||||
function buildDesired(serverId, authored) {
|
||||
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
|
||||
|
||||
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
|
||||
const groupNames = new Set(scopedGroups.map((group) => group.name))
|
||||
|
||||
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
||||
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
||||
const managed = new Set()
|
||||
const rows = []
|
||||
|
||||
for (const group of scopedGroups)
|
||||
rows.push({ kind: 'group', subject: group.name, object: '' })
|
||||
|
||||
for (const row of groupPermissions) {
|
||||
if (!groupNames.has(row.groupName)) continue
|
||||
|
||||
const permission = normaliseName(row.permission)
|
||||
permissionsByGroup.get(row.groupName).push(permission)
|
||||
managed.add(permission)
|
||||
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
|
||||
}
|
||||
|
||||
const seenMember = new Set()
|
||||
|
||||
for (const row of members) {
|
||||
if (!groupNames.has(row.groupName)) continue
|
||||
|
||||
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||
const key = `${row.groupName}:${steamId}`
|
||||
if (seenMember.has(key)) continue
|
||||
seenMember.add(key)
|
||||
|
||||
membersByGroup.get(row.groupName).push(steamId)
|
||||
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
|
||||
}
|
||||
}
|
||||
|
||||
const permissionsBySteamId = new Map()
|
||||
const seenGrant = new Set()
|
||||
|
||||
for (const row of grants) {
|
||||
if (!inScope(row.scope, serverId)) continue
|
||||
|
||||
const permission = normaliseName(row.permission)
|
||||
|
||||
// Managed whether or not it reaches anybody: the namespace is what makes a
|
||||
// hand grant of this permission to somebody else show up as drift, and a
|
||||
// grant whose holder has linked nothing would otherwise silently narrow it.
|
||||
managed.add(permission)
|
||||
|
||||
// **Resolved from the link map, not from the row.** `listGrants` joins the
|
||||
// links and therefore repeats a grant once per linked account, which would
|
||||
// give the right answer here by accident — until somebody changes that query
|
||||
// and one of a person's two accounts quietly stops being granted. The map is
|
||||
// the same source the members above use, and it says what it means.
|
||||
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||
const key = `${steamId}:${permission}`
|
||||
if (seenGrant.has(key)) continue
|
||||
seenGrant.add(key)
|
||||
|
||||
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
||||
permissionsBySteamId.get(steamId).push(permission)
|
||||
rows.push({ kind: 'grant', subject: steamId, object: permission })
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
groups: scopedGroups.map((group) => ({
|
||||
name: group.name,
|
||||
title: group.title || group.name,
|
||||
rank: group.rank,
|
||||
permissions: permissionsByGroup.get(group.name),
|
||||
members: membersByGroup.get(group.name),
|
||||
})),
|
||||
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
|
||||
steamId,
|
||||
permissions,
|
||||
})),
|
||||
managed: [...managed].sort(),
|
||||
}
|
||||
|
||||
return { payload, rows, hash: hashRows(rows) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A digest of the desired set.
|
||||
*
|
||||
* Sorted before hashing, because the rows come out of several queries in an
|
||||
* order nothing guarantees — an unsorted digest would differ between two reads
|
||||
* of an unchanged set and push to every game server on every tick.
|
||||
*/
|
||||
function hashRows(rows) {
|
||||
const canonical = rows
|
||||
.map((row) => `${row.kind} | ||||