Compare commits
14 Commits
9018e55488
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| b1abd87c3d | |||
| f35e70e7d3 | |||
| 43147b796a | |||
| a1b6d155a1 | |||
| 0876a1d568 | |||
| f3e274b33d | |||
| 0a1e558942 | |||
| baffaa46c9 | |||
| 28e46771b1 | |||
| 8df850f73e | |||
| 7e1f037aad | |||
| 22fd8c5da7 | |||
| 5ce711048c | |||
| f211969ee1 |
40
README.md
40
README.md
@@ -36,17 +36,51 @@ rows here; the website core never learns there is more than one.
|
|||||||
| Surface | Route |
|
| Surface | Route |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
|
| 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` |
|
| 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,
|
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
|
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
|
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.
|
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
|
## Build and check
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -28,11 +28,14 @@
|
|||||||
],
|
],
|
||||||
"server": [
|
"server": [
|
||||||
"boot.js",
|
"boot.js",
|
||||||
|
"catalogue.js",
|
||||||
"core.js",
|
"core.js",
|
||||||
"db",
|
"db",
|
||||||
"index.js",
|
"index.js",
|
||||||
|
"ingest.js",
|
||||||
"model",
|
"model",
|
||||||
"package.json",
|
"package.json",
|
||||||
|
"permSync.js",
|
||||||
"router",
|
"router",
|
||||||
"sidecarClient.js"
|
"sidecarClient.js"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,6 +25,45 @@ const { request: req, BASE } = rg.api
|
|||||||
// registers under the `/rust` prefix `module.json` declares.
|
// registers under the `/rust` prefix `module.json` declares.
|
||||||
export const servers = {
|
export const servers = {
|
||||||
list: () => req('/public/rust/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 ────────────────────────────────────────────────────────────────
|
// ── player ────────────────────────────────────────────────────────────────
|
||||||
@@ -35,6 +74,20 @@ export const playerServers = {
|
|||||||
list: () => req('/player/rust/servers'),
|
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 ─────────────────────────────────────────────────────────────────
|
// ── admin ─────────────────────────────────────────────────────────────────
|
||||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
// **`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
|
// and a save that omits the field leaves the stored credential alone — so an
|
||||||
@@ -50,8 +103,93 @@ export const admin = {
|
|||||||
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
|
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 } : {} }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||||
// download link, an EventSource. Reach for `request` first.
|
// 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,
|
||||||
|
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,12 @@
|
|||||||
import { registry, coreApiVersion } from './core.js'
|
import { registry, coreApiVersion } from './core.js'
|
||||||
|
|
||||||
import Servers from './routes/public/Servers.jsx'
|
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 UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||||
|
import FooterStatus from './components/FooterStatus.jsx'
|
||||||
|
import { IconKey, IconLink } from './icons.jsx'
|
||||||
|
|
||||||
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||||
// and prefixes every route path with it.
|
// and prefixes every route path with it.
|
||||||
@@ -33,7 +39,7 @@ const ID = 'rust'
|
|||||||
// installed side by side cannot collide, and an operator can see from a URL which
|
// installed side by side cannot collide, and an operator can see from a URL which
|
||||||
// module served it.
|
// 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
|
// **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
|
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||||
@@ -41,11 +47,42 @@ const ID = 'rust'
|
|||||||
// see what, and they only do if one thing decides.
|
// 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.
|
// 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
|
// **The list is registered with an EMPTY path**, which core renders as the
|
||||||
// worse than the 404 an unregistered one gives.
|
// 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, {
|
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 /> }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||||
@@ -67,9 +104,56 @@ registry.registerRoutes(ID, {
|
|||||||
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||||
registry.registerNav(ID, {
|
registry.registerNav(ID, {
|
||||||
area: 'public',
|
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 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 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
|
// `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
|
// 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
|
// 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
|
||||||
65
client/src/icons.jsx
Normal file
65
client/src/icons.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// ── 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>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default { IconLink, IconKey }
|
||||||
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 }
|
||||||
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 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 ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||||
|
Adopt
|
||||||
|
</button>
|
||||||
|
<button type="button" className="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 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 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 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 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 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 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 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 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
|
// 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
|
// 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).
|
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
|
||||||
//
|
//
|
||||||
// **Render `PublicLayout` yourself.** Core wraps public routes in its maintenance
|
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
|
||||||
// gate and nothing else, so a page that omits the layout renders bare — no
|
// routes in its maintenance gate and nothing else, so a page that omits the
|
||||||
// header, no footer, no site chrome — which looks like a bug and is the contract
|
// layout renders bare; without a `shell` it renders full-bleed with the footer
|
||||||
// (§3.3). Admin and player routes are the other way round: core wraps those.
|
// 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
|
// **This page never calls a game server.** Every field it renders comes from
|
||||||
// centred column, the vertical padding, and the thing that holds the footer at
|
// this module's own tables, written by the ingest cursor, which is what lets it
|
||||||
// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a
|
// render "offline, last seen an hour ago" instead of an error page when a shard
|
||||||
// width, never a class, because the classes belong to core's stylesheet.
|
// is down. The site's availability does not depend on the game's.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
import { ago, count, day } from '../../lib/format.js'
|
||||||
import api from '../../api.js'
|
import api from '../../api.js'
|
||||||
|
|
||||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
/** The "last reported" line, which has three cases and not one. */
|
||||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
function reported(server) {
|
||||||
// one fewer thing an operator ships.
|
if (!server.lastSeenAt) return 'This server has never reported.'
|
||||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
|
||||||
|
return `Last reported ${ago(server.lastSeenAt)}.`
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Servers() {
|
export default function Servers() {
|
||||||
// `useAsync` is core's fetch/loading/error hook, and the components below are
|
// `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
|
// 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.
|
// 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 { data, loading, error } = useAsync(() => api.servers.list(), [])
|
||||||
const servers = data ? data.servers : []
|
const servers = data ? data.servers : []
|
||||||
|
|
||||||
@@ -66,37 +68,54 @@ export default function Servers() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{servers.length > 0 && (
|
{servers.length > 0 && (
|
||||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{servers.map((server) => (
|
{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}
|
key={server.id}
|
||||||
|
to={`/rust/servers/${encodeURIComponent(server.id)}`}
|
||||||
|
className="card"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'baseline',
|
alignItems: 'baseline',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
padding: '0.75rem 0',
|
padding: '16px 20px',
|
||||||
borderBottom: '1px solid rgba(128,128,128,0.25)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<span>
|
||||||
<strong>{server.name}</strong>
|
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
|
||||||
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
|
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
|
||||||
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
|
{[
|
||||||
{/* `stale` is a first-class part of the answer rather than
|
server.level || null,
|
||||||
something the page infers from a timestamp. The server
|
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||||
decides what counts as stale, because the server is what
|
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||||
knows how often a sidecar is supposed to check in. */}
|
]
|
||||||
Last reported {ago(server.updatedAt)}
|
.filter(Boolean)
|
||||||
{server.stale ? ' — out of date, so it is shown as offline.' : '.'}
|
.join(' · ')}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
|
||||||
<div style={{ whiteSpace: 'nowrap' }}>
|
{/* `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.online
|
||||||
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
|
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||||
: 'Offline'}
|
: 'Offline'}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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' },
|
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||||
registry: {
|
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) {
|
registerRoutes(id, byArea) {
|
||||||
for (const [area, list] of Object.entries(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 }) {
|
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')
|
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||||
for (const [area, list] of Object.entries(registered.routes)) {
|
for (const [area, list] of Object.entries(registered.routes)) {
|
||||||
for (const r of list) {
|
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`)
|
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')
|
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', () => {
|
it('every slot module.json declares is one the chunk fills', () => {
|
||||||
// `module.json` declares SERVER slots, and the loader validates those before
|
// `module.json` declares SERVER slots, and the loader validates those before
|
||||||
// the chunk is ever served. Client slots cannot be declared there — the server
|
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||||
|
|||||||
@@ -12,5 +12,6 @@
|
|||||||
"admin": ["/rust"],
|
"admin": ["/rust"],
|
||||||
"player": ["/rust"]
|
"player": ["/rust"]
|
||||||
},
|
},
|
||||||
"capabilities": ["servers"]
|
"extensions": ["admin.users.detail"],
|
||||||
|
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,71 @@
|
|||||||
{
|
{
|
||||||
"$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.",
|
"$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": [
|
"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",
|
"method": "DELETE",
|
||||||
"path": "/api/v1/admin/rust/servers/:id",
|
"path": "/api/v1/admin/rust/servers/:id",
|
||||||
"tier": "public"
|
"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/permissions",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/catalogue",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/rust/servers",
|
"path": "/api/v1/admin/rust/servers",
|
||||||
"tier": "public"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/rust/servers",
|
"path": "/api/v1/player/rust/servers",
|
||||||
@@ -21,11 +76,76 @@
|
|||||||
"path": "/api/v1/public/rust/servers",
|
"path": "/api/v1/public/rust/servers",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/events",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/leaderboard",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/online",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||||
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/rust/servers/:id/test",
|
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||||
"tier": "public"
|
"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",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/rust/servers/:id",
|
"path": "/api/v1/admin/rust/servers/:id",
|
||||||
|
|||||||
126
server/boot.js
126
server/boot.js
@@ -21,25 +21,53 @@
|
|||||||
// letting that fail the boot would make installing the module before installing
|
// letting that fail the boot would make installing the module before installing
|
||||||
// the bridge impossible.
|
// the bridge impossible.
|
||||||
//
|
//
|
||||||
// ── Polling, in phase 1 ───────────────────────────────────────────────────
|
// ── Three timers, and they answer three different questions ───────────────
|
||||||
//
|
//
|
||||||
// This is a poll, and the live feed it will become is a later phase's work. The
|
// refresh (30s) what is each server, and who is on it — the BOARDS
|
||||||
// poll is not a placeholder for it: a sidecar's store-backed reads are exactly
|
// ingest (5s) what has happened since we last looked — the CURSOR
|
||||||
// what answers while a game server is off, and the module will keep reading them
|
// prune (1h) forgetting the detail we promised not to keep for ever
|
||||||
// on an interval to notice a server that went away without saying anything.
|
//
|
||||||
// What the feed adds is latency, not coverage.
|
// The boards poll and the ingest are deliberately separate rather than one loop
|
||||||
|
// reading both. They fail differently and they matter differently: a board that
|
||||||
|
// is 30 seconds stale shows a player count slightly behind, and an ingest that
|
||||||
|
// is 30 seconds behind shows a killfeed that feels broken. Splitting them lets
|
||||||
|
// the cheap one run often and the expensive one run rarely, and it means a
|
||||||
|
// sidecar that answers one and not the other degrades in exactly one place.
|
||||||
|
//
|
||||||
|
// The poll was never a placeholder for a socket: a sidecar's store-backed reads
|
||||||
|
// are what answer while a game server is off, which is most of what this module
|
||||||
|
// renders. See `ingest.js` for why the live feed is a cursor and not a
|
||||||
|
// WebSocket.
|
||||||
|
|
||||||
const core = require('./core')
|
const core = require('./core')
|
||||||
|
|
||||||
const db = require('./model/servers/servers.db')
|
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 servers = require('./model/servers/servers.model')
|
||||||
const sidecar = require('./sidecarClient')
|
const sidecar = require('./sidecarClient')
|
||||||
|
|
||||||
const log = core.logger('boot')
|
const log = core.logger('boot')
|
||||||
|
|
||||||
let refreshTimer = null
|
let refreshTimer = null
|
||||||
|
let ingestTimer = null
|
||||||
|
let pruneTimer = null
|
||||||
|
|
||||||
const REFRESH_MS = 30 * 1000
|
const REFRESH_MS = 30 * 1000
|
||||||
|
const INGEST_MS = 5 * 1000
|
||||||
|
const PRUNE_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long this module keeps raw events.
|
||||||
|
*
|
||||||
|
* Longer than the sidecar's 14 days, because this is the richer store and the
|
||||||
|
* one a page reads — and because the sidecar lives on somebody's game host while
|
||||||
|
* this lives on the website's own database. What is NOT bounded by it is the
|
||||||
|
* record: `rust_player_wipe_stats` and `rust_gather_totals` are permanent, which
|
||||||
|
* is the whole of R12's "a wipe does not erase a player's history".
|
||||||
|
*/
|
||||||
|
const EVENT_RETENTION_DAYS = 30
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask every configured sidecar how its server is doing, and store what it said.
|
* Ask every configured sidecar how its server is doing, and store what it said.
|
||||||
@@ -63,7 +91,10 @@ async function refresh() {
|
|||||||
|
|
||||||
async function refreshOne(server) {
|
async function refreshOne(server) {
|
||||||
try {
|
try {
|
||||||
const board = await sidecar.serverBoard(server)
|
// One call for both boards. `/server` would answer the same question about
|
||||||
|
// the server itself, but presence would then be a second round trip to the
|
||||||
|
// same process for a fact it already had in hand.
|
||||||
|
const board = await sidecar.boards(server)
|
||||||
|
|
||||||
// Three outcomes, and collapsing any two of them loses something an operator
|
// Three outcomes, and collapsing any two of them loses something an operator
|
||||||
// needs:
|
// needs:
|
||||||
@@ -76,16 +107,29 @@ async function refreshOne(server) {
|
|||||||
// whose plugin is not loaded yet, and reporting it as unreachable sends the
|
// 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.
|
// operator to look at the network instead of at the game server.
|
||||||
if (!board.ok) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const frame = board.data
|
const boards = (board.data && board.data.boards) || {}
|
||||||
|
const frame = boards['server.hello']
|
||||||
|
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
await db.putState({ serverId: server.id, reachable: true, online: false })
|
// 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.markUnreachable(server.id, true)
|
||||||
|
await ingest.applyBoards(server.id, {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ingest.applyBoards(server.id, boards)
|
||||||
|
|
||||||
await db.putState({
|
await db.putState({
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
@@ -102,6 +146,7 @@ async function refreshOne(server) {
|
|||||||
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
|
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
|
||||||
bootId: frame.bootId || null,
|
bootId: frame.bootId || null,
|
||||||
saveCreatedAt: frame.saveCreatedAt || null,
|
saveCreatedAt: frame.saveCreatedAt || null,
|
||||||
|
wipeId: frame.wipeId || null,
|
||||||
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
|
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
|
||||||
raw: frame,
|
raw: frame,
|
||||||
})
|
})
|
||||||
@@ -119,14 +164,49 @@ async function refreshOne(server) {
|
|||||||
* built to look like it — so a module that only needs core at boot time can skip
|
* built to look like it — so a module that only needs core at boot time can skip
|
||||||
* `core.init` entirely and use this argument.
|
* `core.init` entirely and use this argument.
|
||||||
*/
|
*/
|
||||||
|
/** Runs the cursor for every configured server, independently. */
|
||||||
|
async function ingestAll() {
|
||||||
|
let rows
|
||||||
|
|
||||||
|
try {
|
||||||
|
rows = await servers.listForPolling()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not read the server list', { error: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// `allSettled`, for the same reason the board poll uses it: six servers behind
|
||||||
|
// one unreachable host must not stop the other five being ingested.
|
||||||
|
await Promise.allSettled(rows.map((server) => ingest.ingestServer(server)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prune() {
|
||||||
|
try {
|
||||||
|
const gone = await eventsDb.pruneEvents(EVENT_RETENTION_DAYS)
|
||||||
|
if (gone > 0) log.info('pruned old events', { events: gone, days: EVENT_RETENTION_DAYS })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('could not prune events', { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onBoot() {
|
async function onBoot() {
|
||||||
await refresh()
|
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)
|
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||||
|
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
||||||
|
pruneTimer = setInterval(prune, PRUNE_MS)
|
||||||
// Node keeps the process alive for a pending timer. Core's own intervals are
|
// Node keeps the process alive for a pending timer. Core's own intervals are
|
||||||
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
||||||
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
||||||
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
|
||||||
log.info('booted', { refreshMs: REFRESH_MS })
|
if (timer && typeof timer.unref === 'function') timer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,9 +218,27 @@ async function onBoot() {
|
|||||||
* rather than cancelled, since nothing can stop a promise that is still running.
|
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||||
*/
|
*/
|
||||||
async function onShutdown() {
|
async function onShutdown() {
|
||||||
if (refreshTimer) clearInterval(refreshTimer)
|
permSync.stop()
|
||||||
|
|
||||||
|
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
}
|
||||||
|
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
|
ingestTimer = null
|
||||||
|
pruneTimer = null
|
||||||
|
|
||||||
log.info('shut down')
|
log.info('shut down')
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }
|
module.exports = {
|
||||||
|
onBoot,
|
||||||
|
onShutdown,
|
||||||
|
refresh,
|
||||||
|
refreshOne,
|
||||||
|
ingestAll,
|
||||||
|
prune,
|
||||||
|
REFRESH_MS,
|
||||||
|
INGEST_MS,
|
||||||
|
EVENT_RETENTION_DAYS,
|
||||||
|
}
|
||||||
|
|||||||
129
server/catalogue.js
Normal file
129
server/catalogue.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// ── What the bridge can say, and who may hear it ──────────────────────────
|
||||||
|
//
|
||||||
|
// One file, because these two questions have to be answered together or the
|
||||||
|
// second one rots: which frame kinds exist, and which of them a member of the
|
||||||
|
// public may see.
|
||||||
|
//
|
||||||
|
// ── The boundary ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Protocol 2's catalogue includes frames carrying **IP addresses** (a login
|
||||||
|
// attempt, an approval, a ban) and **one player's complaint about another** (a
|
||||||
|
// report), and one — a destroyed structure — that names where somebody lives.
|
||||||
|
// They are stored, because an operator chasing ban evasion needs them and
|
||||||
|
// because the sidecar persists what it is told. They must never reach a public
|
||||||
|
// page.
|
||||||
|
//
|
||||||
|
// **The boundary is enforced HERE, on the side that serves, and not on the wire.**
|
||||||
|
// The plugin could have stamped a `class` on every frame and saved this file the
|
||||||
|
// trouble; it deliberately does not (PROTOCOL.md §8.5). A boundary declared by
|
||||||
|
// the sender is a boundary a compromised — or merely out-of-date — game host can
|
||||||
|
// widen. Core's own shard fan-out works the same way: a public stream with an
|
||||||
|
// allowlist of kinds, and an admin stream that adds the rest.
|
||||||
|
//
|
||||||
|
// ── Default deny, and why it is not paranoia ──────────────────────────────
|
||||||
|
//
|
||||||
|
// `isPublic` answers `false` for a kind it has never heard of. That matters
|
||||||
|
// because of the shape of the mistake it prevents: the next protocol version
|
||||||
|
// adds a kind, this module ingests it happily (`rust_events` stores what it is
|
||||||
|
// given), and a page that filtered by a DENY list would publish it the day it
|
||||||
|
// first arrived — before anybody had decided whether it should be public. With
|
||||||
|
// an allowlist the new kind is invisible until somebody adds it here, which is
|
||||||
|
// the same moment they think about it.
|
||||||
|
//
|
||||||
|
// The test holds this list against `docs/rust-link/PROTOCOL.md` §8.4's table, so
|
||||||
|
// adding a kind to the spec without classifying it fails a build rather than
|
||||||
|
// shipping an address to a public page.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds a public, signed-out visitor may see.
|
||||||
|
*
|
||||||
|
* Each entry is a decision. `player.chat` is here because a shard's chat is
|
||||||
|
* public by the same logic that makes a killfeed public — it happened in front
|
||||||
|
* of everyone who was on the server — and an operator who disagrees turns the
|
||||||
|
* feature off rather than relying on this list being wrong.
|
||||||
|
*/
|
||||||
|
const PUBLIC_KINDS = Object.freeze([
|
||||||
|
'player.connected',
|
||||||
|
'player.disconnected',
|
||||||
|
'player.respawned',
|
||||||
|
'player.death',
|
||||||
|
'player.chat',
|
||||||
|
'player.tally',
|
||||||
|
'server.wipe',
|
||||||
|
'server.initialized',
|
||||||
|
'server.shutdown',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds an admin may see and nobody else.
|
||||||
|
*
|
||||||
|
* Listed rather than implied by absence, so that "we know about this kind and it
|
||||||
|
* is restricted" is distinguishable from "nobody has classified this kind" — the
|
||||||
|
* second is a finding, and a bare allowlist cannot tell you which you are
|
||||||
|
* looking at.
|
||||||
|
*/
|
||||||
|
const STAFF_KINDS = Object.freeze([
|
||||||
|
'entity.destroyed',
|
||||||
|
'player.reported',
|
||||||
|
'player.banned',
|
||||||
|
'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 3 defines. */
|
||||||
|
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||||
|
|
||||||
|
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||||
|
const STAFF = new Set(STAFF_KINDS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May a signed-out visitor see this kind?
|
||||||
|
*
|
||||||
|
* Default deny: an unknown kind is not public. Callers pass whatever arrived on
|
||||||
|
* the wire, including a kind from a newer protocol this build has never seen.
|
||||||
|
*/
|
||||||
|
function isPublic(kind) {
|
||||||
|
return PUBLIC.has(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this a kind this build knows about at all? */
|
||||||
|
function isKnown(kind) {
|
||||||
|
return PUBLIC.has(kind) || STAFF.has(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows a list of requested kinds to the ones a viewer may have.
|
||||||
|
*
|
||||||
|
* Returning the allowlist itself when nothing was requested is what makes the
|
||||||
|
* public route safe by construction rather than by remembering to filter: there
|
||||||
|
* is no code path where "no filter" means "everything".
|
||||||
|
*/
|
||||||
|
function kindsFor({ admin = false, requested = null } = {}) {
|
||||||
|
const permitted = admin ? ALL_KINDS : PUBLIC_KINDS
|
||||||
|
|
||||||
|
if (!requested || requested.length === 0) return [...permitted]
|
||||||
|
|
||||||
|
const allowed = new Set(permitted)
|
||||||
|
return requested.filter((k) => allowed.has(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PUBLIC_KINDS,
|
||||||
|
STAFF_KINDS,
|
||||||
|
ALL_KINDS,
|
||||||
|
isPublic,
|
||||||
|
isKnown,
|
||||||
|
kindsFor,
|
||||||
|
}
|
||||||
@@ -19,5 +19,24 @@
|
|||||||
-- it knows this module registered, because it is the side that knows which
|
-- it knows this module registered, because it is the side that knows which
|
||||||
-- registrant owned what.
|
-- registrant owned what.
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
DROP TABLE IF EXISTS rust_gather_totals;
|
||||||
|
DROP TABLE IF EXISTS rust_player_wipe_stats;
|
||||||
|
DROP TABLE IF EXISTS rust_players;
|
||||||
|
DROP TABLE IF EXISTS rust_wipes;
|
||||||
DROP TABLE IF EXISTS rust_server_state;
|
DROP TABLE IF EXISTS rust_server_state;
|
||||||
DROP TABLE IF EXISTS rust_servers;
|
DROP TABLE IF EXISTS rust_servers;
|
||||||
|
|||||||
@@ -14,14 +14,20 @@
|
|||||||
-- Every table here is prefixed `rust_`, which is this module's id and the only
|
-- Every table here is prefixed `rust_`, which is this module's id and the only
|
||||||
-- prefix it may create under.
|
-- prefix it may create under.
|
||||||
--
|
--
|
||||||
-- ── Two tables, and the split between them is the whole design ────────────
|
-- ── Four kinds of table, and the split between them is the whole design ───
|
||||||
--
|
--
|
||||||
-- `rust_servers` is CONFIGURATION: rows an operator writes, from Admin → Rust.
|
-- CONFIGURATION `rust_servers` — rows an operator writes, from Admin → Rust.
|
||||||
-- `rust_server_state` is OBSERVED STATE: rows this module writes from what a
|
-- OBSERVED STATE `rust_server_state`, `rust_presence` — what a sidecar last
|
||||||
-- sidecar reported. They are separate tables rather than columns on one because
|
-- reported, replaced rather than appended.
|
||||||
-- they have different writers, different lifetimes and different audiences —
|
-- THE RECORD `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||||
-- and because a purge of observed state while keeping the configuration is a
|
-- `rust_gather_totals` — permanent, and the reason a wipe does
|
||||||
-- thing an operator will eventually want.
|
-- not erase a player's history.
|
||||||
|
-- THE WINDOW `rust_events` — recent detail, bounded by a sweep.
|
||||||
|
--
|
||||||
|
-- They are separate tables rather than columns on one because they have
|
||||||
|
-- different writers, different lifetimes and different audiences — and because
|
||||||
|
-- a purge of observed state while keeping the configuration is a thing an
|
||||||
|
-- operator will eventually want.
|
||||||
--
|
--
|
||||||
-- Teardown is `purge.sql`, which no boot ever runs.
|
-- Teardown is `purge.sql`, which no boot ever runs.
|
||||||
|
|
||||||
@@ -97,3 +103,518 @@ CREATE TABLE IF NOT EXISTS rust_server_state (
|
|||||||
CONSTRAINT fk_rust_server_state_server
|
CONSTRAINT fk_rust_server_state_server
|
||||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The read path ─────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Protocol 2 turned the bridge from a greeting into a catalogue, and these are
|
||||||
|
-- the tables that hold it. They divide on one line, and it is the line R12 drew:
|
||||||
|
--
|
||||||
|
-- PERMANENT `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||||
|
-- `rust_gather_totals` — a player's record, kept for ever. All-time
|
||||||
|
-- is a SUM across wipes rather than a second set of counters, so
|
||||||
|
-- there is no second number that can disagree with the first.
|
||||||
|
--
|
||||||
|
-- BOUNDED `rust_events` — the recent raw window the killfeed reads, pruned
|
||||||
|
-- on a sweep. It is detail, not record: losing last month's
|
||||||
|
-- individual deaths costs a scroll-back, losing last month's
|
||||||
|
-- totals costs a player their history.
|
||||||
|
--
|
||||||
|
-- DERIVED `rust_presence` — who is on right now, replaced wholesale from
|
||||||
|
-- the `players.online` board. Never a history, never appended.
|
||||||
|
--
|
||||||
|
-- The sidecar keeps its own bounded copy of the same events (default 14 days),
|
||||||
|
-- so shortening either window loses recent detail and neither loses a total.
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Wipes ─────────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- One row per (server, wipe). The id is the plugin's, derived from the save's
|
||||||
|
-- creation time and stamped on every frame (PROTOCOL.md §8.2) — this module
|
||||||
|
-- never derives one, because two derivations of one fact eventually disagree
|
||||||
|
-- about a boundary.
|
||||||
|
--
|
||||||
|
-- Rows appear by being MENTIONED: the first frame carrying a wipe id this module
|
||||||
|
-- has not seen creates it. There is no "start a wipe" call and there must not be
|
||||||
|
-- one, because the website is not present when a wipe happens — a wipe is a fact
|
||||||
|
-- about a world that was restarted while nobody was watching.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_wipes (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NOT NULL,
|
||||||
|
save_created_at VARCHAR(32) NULL,
|
||||||
|
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (server_id, wipe_id),
|
||||||
|
CONSTRAINT fk_rust_wipes_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Players ───────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Identity, and deliberately nothing else. It is keyed on the Steam id alone
|
||||||
|
-- and carries no server: a player is the same person on all six of a community's
|
||||||
|
-- servers, and everything that is per-server lives in the stats table.
|
||||||
|
--
|
||||||
|
-- `user_id` is NOT here. Linking a Steam id to a website account is phase 6's
|
||||||
|
-- work (R1), and a column waiting for it would be a column every read has to
|
||||||
|
-- remember is always null.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_players (
|
||||||
|
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(191) NULL,
|
||||||
|
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The permanent record ──────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- One row per player per wipe per server, and the only counters this module
|
||||||
|
-- keeps. R12's "per-wipe detail plus all-time rollups" is satisfied by SUMming
|
||||||
|
-- this rather than by maintaining a second all-time row, because two counters
|
||||||
|
-- for one fact drift the first time an ingest is replayed.
|
||||||
|
--
|
||||||
|
-- Every column is a COUNT that only ever goes up within a wipe, which is what
|
||||||
|
-- makes ingest idempotent-ish in the only way that matters: the cursor advances
|
||||||
|
-- only after the batch commits, so a crash re-reads a batch it has not counted.
|
||||||
|
--
|
||||||
|
-- `playtime_sec` comes from `sessionSec` on a disconnect, and a session whose
|
||||||
|
-- start this module never saw contributes NOTHING rather than zero — the plugin
|
||||||
|
-- omits the field, the ingest skips it, and the number stays honestly short
|
||||||
|
-- instead of quietly wrong.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_player_wipe_stats (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
deaths INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
suicides INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
npc_kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
structures INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
sessions INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
playtime_sec BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
last_seen DATETIME NULL,
|
||||||
|
PRIMARY KEY (server_id, wipe_id, steam_id),
|
||||||
|
KEY idx_rust_stats_kills (server_id, wipe_id, kills DESC),
|
||||||
|
KEY idx_rust_stats_player (steam_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── What they gathered ────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- A row per resource rather than a JSON blob on the stats row, for one reason:
|
||||||
|
-- the leaderboard question is "who gathered the most sulfur this wipe", and that
|
||||||
|
-- is an ORDER BY over a column in every SQL engine and a JSON function call in
|
||||||
|
-- exactly one. The resource name is the game's own shortname, unknown in advance
|
||||||
|
-- and not worth a lookup table.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_gather_totals (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
resource VARCHAR(64) NOT NULL,
|
||||||
|
amount BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, wipe_id, steam_id, resource),
|
||||||
|
KEY idx_rust_gather_top (server_id, wipe_id, resource, amount DESC)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The recent raw window ─────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Every ingested event, whole, for as long as the retention sweep keeps it. The
|
||||||
|
-- killfeed reads this; so does an admin looking at what happened.
|
||||||
|
--
|
||||||
|
-- `raw` holds the entire frame and the columns beside it are only what a query
|
||||||
|
-- needs to reach — the same rule the sidecar's own store follows, one hop along:
|
||||||
|
-- a protocol version that adds a field needs no migration here.
|
||||||
|
--
|
||||||
|
-- **`kind` is a security boundary, not a label.** Some kinds carry IP addresses
|
||||||
|
-- and player reports (PROTOCOL.md §8.4), and what makes them safe is that the
|
||||||
|
-- public read is filtered by an allowlist this module holds, default-deny. The
|
||||||
|
-- rows are stored either way, because an operator chasing ban evasion needs them.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_events (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
wipe_id VARCHAR(48) NULL,
|
||||||
|
kind VARCHAR(64) NOT NULL,
|
||||||
|
t BIGINT NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NULL,
|
||||||
|
raw LONGTEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_rust_events_server (server_id, id DESC),
|
||||||
|
KEY idx_rust_events_kind (server_id, kind, id DESC),
|
||||||
|
KEY idx_rust_events_wipe (server_id, wipe_id, id DESC),
|
||||||
|
KEY idx_rust_events_created (created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Who is on right now ───────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Replaced wholesale every time the `players.online` board arrives, which is on
|
||||||
|
-- every bridge connect and every 60 seconds. It is a BOARD, and the reason it is
|
||||||
|
-- its own table rather than rows in `rust_events` is that a board answers "now"
|
||||||
|
-- and an event answers "then"; storing a board as history is the mistake the
|
||||||
|
-- wire's `type` field exists to prevent, and it would be a shame to make it here
|
||||||
|
-- after the sidecar went to the trouble of not making it there.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_presence (
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
name VARCHAR(191) NULL,
|
||||||
|
sleeping TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
connected_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (server_id, steam_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── The ingest cursor ─────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Where this module has read up to in each sidecar's feed. One row per server.
|
||||||
|
--
|
||||||
|
-- It is persisted rather than held in memory because the alternative is a module
|
||||||
|
-- that re-reads everything on every boot or nothing at all, and both are wrong in
|
||||||
|
-- a way that only shows up in production. The cursor advances **after** the batch
|
||||||
|
-- is written, never before: a crash mid-batch re-reads rows it has not counted,
|
||||||
|
-- which is the safe direction to be wrong in.
|
||||||
|
--
|
||||||
|
-- A NEW server starts at the sidecar's current end rather than at zero (see
|
||||||
|
-- `GET /feed` with no `since`). A module installed today against a sidecar that
|
||||||
|
-- has been running a month wants what happens next — replaying a fortnight of
|
||||||
|
-- deaths into stats whose wipes it never saw is not a catch-up, it is a
|
||||||
|
-- fabrication of history it was not present for.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
|
||||||
|
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
last_event_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
events_seen BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_rust_cursor_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 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`
|
||||||
|
-- does nothing against a database that already has the table, so an edited column
|
||||||
|
-- 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;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const publicRust = require('./router/public/rust.router')
|
const publicRust = require('./router/public/rust.router')
|
||||||
const playerRust = require('./router/player/rust.router')
|
const playerRust = require('./router/player/rust.router')
|
||||||
const adminRust = require('./router/admin/rust.router')
|
const adminRust = require('./router/admin/rust.router')
|
||||||
|
const usersRust = require('./router/admin/usersRust.router')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -78,6 +79,20 @@ module.exports = function register(ctx, api) {
|
|||||||
admin: { '/rust': adminRust },
|
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
|
// 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
|
// 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.
|
// 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
|
// Everything else this module will register — the Team provider, the event
|
||||||
// triggers and audiences, the engagement seeds, the four event catalogues, the
|
// triggers and audiences, the engagement seeds, the four event catalogues, the
|
||||||
// notification streams, the slash commands and the two extension slots — is
|
// notification streams and the slash commands — is deliberately absent. Each
|
||||||
// deliberately absent. Each arrives with the phase that has something real to
|
// arrives with the phase that has something real to put in it. A registration
|
||||||
// put in it. A registration with nothing behind it is worse than a missing one:
|
// with nothing behind it is worse than a missing one: a declared trigger
|
||||||
// a declared trigger nothing emits and a declared slot nothing fills are both
|
// nothing emits and a declared slot nothing fills are both surfaces an operator
|
||||||
// surfaces an operator can configure and then wait on.
|
// can configure and then wait on.
|
||||||
|
|
||||||
log.info('registered', {
|
log.info('registered', {
|
||||||
version: require('../module.json').version,
|
version: require('../module.json').version,
|
||||||
routes: 'public:/rust player:/rust admin:/rust',
|
routes: 'public:/rust player:/rust admin:/rust',
|
||||||
|
extensions: 'admin.users.detail',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
289
server/ingest.js
Normal file
289
server/ingest.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// ── Reading a sidecar's feed, and turning it into a record ────────────────
|
||||||
|
//
|
||||||
|
// One job: move each server's cursor forward, and apply what it passed.
|
||||||
|
//
|
||||||
|
// ── Why a cursor and not a socket ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The obvious design is a WebSocket — the sidecar has one, and module-uo takes
|
||||||
|
// exactly that route for the UO bridge. This module polls a cursor instead, and
|
||||||
|
// the reason is not laziness about latency.
|
||||||
|
//
|
||||||
|
// Core runs on Node 20, where a global `WebSocket` is still behind a flag, so a
|
||||||
|
// socket means taking `ws` as a runtime dependency — and this module's release
|
||||||
|
// asserts that it has none (D5: everything it needs arrives on `ctx`, and the
|
||||||
|
// bundle ships no `node_modules`). That is a cost worth paying for latency, but
|
||||||
|
// the deciding argument is the other one: **a socket needs a cursor anyway.**
|
||||||
|
// Whatever a feed misses while a module is restarting has to be caught up from
|
||||||
|
// somewhere, and the catch-up path is the one that must be right. A socket on
|
||||||
|
// top of a cursor is two mechanisms where the second is load-bearing; a cursor
|
||||||
|
// alone is one mechanism that is exercised every few seconds rather than only
|
||||||
|
// after an outage nobody planned.
|
||||||
|
//
|
||||||
|
// What it costs is seconds of latency on a killfeed. What it buys is that the
|
||||||
|
// path which recovers from a five-hour outage is the same path that ran a moment
|
||||||
|
// ago.
|
||||||
|
//
|
||||||
|
// ── The ordering the whole thing rests on ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// **The cursor advances after the batch is written, never before.** A crash
|
||||||
|
// between the two re-reads events already counted, which inflates a total; a
|
||||||
|
// crash the other way round loses them silently and for ever. Neither is good and
|
||||||
|
// they are not equally bad — one is visible and bounded, the other is invisible
|
||||||
|
// and permanent — so the code is arranged to fail in the visible direction.
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
/** How many events to ask for at once. */
|
||||||
|
const BATCH = 200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many batches one tick will drain before letting the loop breathe.
|
||||||
|
*
|
||||||
|
* A module that has been down for a day has thousands of events waiting, and
|
||||||
|
* draining them in one unbounded loop would hold the tick — and a pool
|
||||||
|
* connection — for as long as that takes. Bounded, it catches up over several
|
||||||
|
* ticks and the site stays responsive while it does.
|
||||||
|
*/
|
||||||
|
const MAX_BATCHES_PER_TICK = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one feed item.
|
||||||
|
*
|
||||||
|
* Every frame is stored raw, and only some of them move a counter. That split is
|
||||||
|
* deliberate: the raw row is what an admin reads and what a later phase can
|
||||||
|
* re-derive from, and the counters are what a leaderboard sums. A kind this
|
||||||
|
* build has never heard of still lands in `rust_events` — it costs nothing and
|
||||||
|
* the alternative is losing the one copy of an event the next version will know
|
||||||
|
* how to read.
|
||||||
|
*/
|
||||||
|
async function apply(serverId, item) {
|
||||||
|
const frame = (item && item.frame) || {}
|
||||||
|
const kind = item.kind || frame.kind
|
||||||
|
const wipeId = frame.wipeId || null
|
||||||
|
|
||||||
|
// A wipe exists because something mentioned it. There is no "a wipe started"
|
||||||
|
// call and there must not be one: the website is not there when a wipe happens.
|
||||||
|
await db.touchWipe(serverId, wipeId, frame.saveCreatedAt || null)
|
||||||
|
|
||||||
|
await db.insertEvent({
|
||||||
|
serverId,
|
||||||
|
wipeId,
|
||||||
|
kind,
|
||||||
|
t: Number(frame.t) || item.t || Date.now(),
|
||||||
|
steamId: frame.steamId || null,
|
||||||
|
raw: frame,
|
||||||
|
})
|
||||||
|
|
||||||
|
const at = { serverId, wipeId, steamId: frame.steamId }
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'player.connected':
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'player.disconnected': {
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
|
||||||
|
// `sessionSec` is ABSENT when the plugin never saw the connect — a player
|
||||||
|
// already on the server when it loaded. Absent is not zero: adding a zero
|
||||||
|
// would be recording a session of no length, which is a different claim
|
||||||
|
// from recording no session, and it is the one that quietly under-reports
|
||||||
|
// playtime for ever.
|
||||||
|
const seconds = Number(frame.sessionSec)
|
||||||
|
await db.addStats(at, {
|
||||||
|
sessions: Number.isFinite(seconds) ? 1 : 0,
|
||||||
|
playtimeSec: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.death': {
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
|
||||||
|
// A suicide is a death AND a suicide, not one instead of the other: the
|
||||||
|
// deaths column is "how many times did this player die", and a leaderboard
|
||||||
|
// that silently omitted self-inflicted ones would disagree with the
|
||||||
|
// killfeed sitting next to it on the same page.
|
||||||
|
await db.addStats(at, { deaths: 1, suicides: frame.attackerType === 'self' ? 1 : 0 })
|
||||||
|
|
||||||
|
// Only a real player's kill counts. `npc` and `environment` have no
|
||||||
|
// attacker to credit, and `self` must not credit the victim with a kill —
|
||||||
|
// which is the one line here that would look right in review and produce a
|
||||||
|
// leaderboard topped by whoever died the most.
|
||||||
|
if (frame.attackerType === 'player' && frame.attackerId) {
|
||||||
|
await db.touchPlayer(frame.attackerId, frame.attackerName || null)
|
||||||
|
await db.addStats({ ...at, steamId: frame.attackerId }, { kills: 1 })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.tally': {
|
||||||
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
|
await db.addStats(at, {
|
||||||
|
npcKills: Number(frame.npcKills) || 0,
|
||||||
|
structures: Number(frame.structures) || 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A tally is a DELTA since the last flush, which is what makes adding it
|
||||||
|
// correct. If it ever becomes a running total this loop doubles every
|
||||||
|
// number in it, slowly, and looks right the whole time.
|
||||||
|
const gathered = frame.gathered || {}
|
||||||
|
for (const [resource, amount] of Object.entries(gathered)) {
|
||||||
|
await db.addGathered(at, resource, Number(amount) || 0)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.chat':
|
||||||
|
case 'player.respawned':
|
||||||
|
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.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings one server's cursor up to date.
|
||||||
|
*
|
||||||
|
* Returns the number of events applied, for the log and for the tests.
|
||||||
|
*/
|
||||||
|
async function ingestServer(server) {
|
||||||
|
const cursor = await db.getCursor(server.id)
|
||||||
|
|
||||||
|
// A server this module has never ingested starts at the sidecar's CURRENT end,
|
||||||
|
// not at zero. A module installed today against a sidecar that has been running
|
||||||
|
// for a month should read what happens next — replaying a fortnight of deaths
|
||||||
|
// into stats for wipes it never saw is not a catch-up, it is inventing a
|
||||||
|
// history it was not present for. `/feed` with no `since` asks exactly that
|
||||||
|
// question, which is why the sidecar answers it that way.
|
||||||
|
if (!cursor) {
|
||||||
|
const tail = await sidecar.feedTail(server)
|
||||||
|
|
||||||
|
if (!tail.ok || !tail.data) {
|
||||||
|
// Unreachable. Write nothing: a cursor of 0 written now would replay the
|
||||||
|
// whole retained history the moment the sidecar came back.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.setCursor(server.id, Number(tail.data.lastId) || 0, 0)
|
||||||
|
log.info('cursor started at the feed tail', { server: server.id, at: tail.data.lastId })
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let since = Number(cursor.lastEventId) || 0
|
||||||
|
let applied = 0
|
||||||
|
|
||||||
|
for (let batch = 0; batch < MAX_BATCHES_PER_TICK; batch += 1) {
|
||||||
|
const res = await sidecar.feed(server, since, BATCH)
|
||||||
|
|
||||||
|
if (!res.ok || !res.data) return applied
|
||||||
|
|
||||||
|
const items = Array.isArray(res.data.items) ? res.data.items : []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
await apply(server.id, item)
|
||||||
|
applied += 1
|
||||||
|
} catch (err) {
|
||||||
|
// One malformed event must not wedge a server's cursor for ever. It is
|
||||||
|
// logged with its id so it can be found, and the cursor moves past it:
|
||||||
|
// the alternative is an ingest that stops at a single bad row and then
|
||||||
|
// silently stops being a feed at all.
|
||||||
|
log.warn('could not apply an event', {
|
||||||
|
server: server.id,
|
||||||
|
id: item && item.id,
|
||||||
|
kind: item && item.kind,
|
||||||
|
error: err.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastId = Number(res.data.lastId)
|
||||||
|
|
||||||
|
if (Number.isFinite(lastId) && lastId > since) {
|
||||||
|
// AFTER the batch. See the header.
|
||||||
|
await db.setCursor(server.id, lastId, items.length)
|
||||||
|
since = lastId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.data.more) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applied > 0) log.info('ingested', { server: server.id, events: applied, cursor: since })
|
||||||
|
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the boards: what is true right now, rather than what happened.
|
||||||
|
*
|
||||||
|
* `players.online` replaces the presence rows wholesale, because that is what a
|
||||||
|
* board is. Storing it as history is the mistake the wire's `type` field exists
|
||||||
|
* to prevent, and it would be a poor return for the sidecar's trouble to make it
|
||||||
|
* here after it went out of its way not to make it there.
|
||||||
|
*/
|
||||||
|
async function applyBoards(serverId, boards) {
|
||||||
|
const presence = boards && boards['players.online']
|
||||||
|
|
||||||
|
if (presence && Array.isArray(presence.players)) {
|
||||||
|
await db.replacePresence(serverId, presence.players)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }
|
||||||
289
server/model/events/events.db.js
Normal file
289
server/model/events/events.db.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// ── SQL for the read path ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Writes come from one caller (`server/ingest.js`) and reads from the routers.
|
||||||
|
// They live together because they are the same tables and the invariants are
|
||||||
|
// easier to keep true when the UPDATE and the SELECT are on the same screen.
|
||||||
|
//
|
||||||
|
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always —
|
||||||
|
// except for one place where a list of kinds is expanded into placeholders, and
|
||||||
|
// that expansion is checked in `events.model.js` before it ever reaches here.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const EVENTS = 'rust_events'
|
||||||
|
const STATS = 'rust_player_wipe_stats'
|
||||||
|
const GATHER = 'rust_gather_totals'
|
||||||
|
const PLAYERS = 'rust_players'
|
||||||
|
const WIPES = 'rust_wipes'
|
||||||
|
const PRESENCE = 'rust_presence'
|
||||||
|
const CURSOR = 'rust_ingest_cursor'
|
||||||
|
|
||||||
|
// ── The cursor ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getCursor(serverId) {
|
||||||
|
const rows = await core.query(
|
||||||
|
`SELECT server_id AS serverId, last_event_id AS lastEventId, events_seen AS eventsSeen
|
||||||
|
FROM ${CURSOR} WHERE server_id = ?`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a server's cursor forward, counting what it passed.
|
||||||
|
*
|
||||||
|
* **Called only after the batch it describes has been written.** The whole
|
||||||
|
* correctness of the ingest is in that ordering: if this ran first, a crash
|
||||||
|
* between the two would skip events for ever, silently, with no way to notice.
|
||||||
|
* Running it last means a crash re-reads events it has already counted at worst
|
||||||
|
* — see `ingest.js` for what makes that survivable.
|
||||||
|
*/
|
||||||
|
async function setCursor(serverId, lastEventId, seen = 0) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${CURSOR} (server_id, last_event_id, events_seen, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_event_id = VALUES(last_event_id),
|
||||||
|
events_seen = events_seen + VALUES(events_seen),
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[serverId, lastEventId, seen],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Writes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function insertEvent({ serverId, wipeId, kind, t, steamId, raw }) {
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${EVENTS} (server_id, wipe_id, kind, t, steam_id, raw)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[serverId, wipeId || null, kind, t, steamId || null, JSON.stringify(raw)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that a wipe exists, from any frame that mentions it.
|
||||||
|
*
|
||||||
|
* There is no "a wipe started" call, because the website is not there when one
|
||||||
|
* does — a wipe happens to a game server that was restarted while nobody was
|
||||||
|
* watching. A wipe is therefore created by being mentioned, and `last_seen`
|
||||||
|
* moves every time it is mentioned again.
|
||||||
|
*/
|
||||||
|
async function touchWipe(serverId, wipeId, saveCreatedAt = null) {
|
||||||
|
if (!wipeId) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${WIPES} (server_id, wipe_id, save_created_at, first_seen, last_seen)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_seen = CURRENT_TIMESTAMP,
|
||||||
|
save_created_at = COALESCE(VALUES(save_created_at), save_created_at)`,
|
||||||
|
[serverId, wipeId, saveCreatedAt],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that a player exists and what they were last called.
|
||||||
|
*
|
||||||
|
* `name` is COALESCEd rather than overwritten so that a frame which carries no
|
||||||
|
* name — a ban by id, a tally — cannot blank out the name every other frame
|
||||||
|
* supplied.
|
||||||
|
*/
|
||||||
|
async function touchPlayer(steamId, name = null) {
|
||||||
|
if (!steamId) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = COALESCE(VALUES(name), name),
|
||||||
|
last_seen = CURRENT_TIMESTAMP`,
|
||||||
|
[steamId, name],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds to one player's counters for one wipe.
|
||||||
|
*
|
||||||
|
* Every column is a running total that only rises within a wipe, so this is an
|
||||||
|
* upsert that ADDS rather than sets. `deltas` names only what moved; a `+ 0` on
|
||||||
|
* everything else is what keeps the caller from having to read the row first.
|
||||||
|
*/
|
||||||
|
async function addStats({ serverId, wipeId, steamId }, deltas = {}) {
|
||||||
|
if (!serverId || !steamId) return
|
||||||
|
|
||||||
|
const cols = ['kills', 'deaths', 'suicides', 'npc_kills', 'structures', 'sessions', 'playtime_sec']
|
||||||
|
const values = {
|
||||||
|
kills: deltas.kills || 0,
|
||||||
|
deaths: deltas.deaths || 0,
|
||||||
|
suicides: deltas.suicides || 0,
|
||||||
|
npc_kills: deltas.npcKills || 0,
|
||||||
|
structures: deltas.structures || 0,
|
||||||
|
sessions: deltas.sessions || 0,
|
||||||
|
playtime_sec: deltas.playtimeSec || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, ${cols.join(', ')}, last_seen)
|
||||||
|
VALUES (?, ?, ?, ${cols.map(() => '?').join(', ')}, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
${cols.map((c) => `${c} = ${c} + VALUES(${c})`).join(',\n ')},
|
||||||
|
last_seen = CURRENT_TIMESTAMP`,
|
||||||
|
[serverId, wipeId || '', steamId, ...cols.map((c) => values[c])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGathered({ serverId, wipeId, steamId }, resource, amount) {
|
||||||
|
if (!serverId || !steamId || !resource || !(amount > 0)) return
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${GATHER} (server_id, wipe_id, steam_id, resource, amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE amount = amount + VALUES(amount)`,
|
||||||
|
[serverId, wipeId || '', steamId, resource, amount],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a server's presence rows with exactly what the board said.
|
||||||
|
*
|
||||||
|
* Two statements, delete then insert, because a board is a REPLACEMENT: a player
|
||||||
|
* who left between two boards has to disappear, and an upsert alone would leave
|
||||||
|
* them online for ever. It is not wrapped in a transaction on purpose — the
|
||||||
|
* window between the two is a fraction of a second of a page possibly showing an
|
||||||
|
* empty player list, against holding a lock on a table two routes read.
|
||||||
|
*/
|
||||||
|
async function replacePresence(serverId, players = []) {
|
||||||
|
await core.query(`DELETE FROM ${PRESENCE} WHERE server_id = ?`, [serverId])
|
||||||
|
|
||||||
|
for (const p of players) {
|
||||||
|
if (!p || !p.steamId) continue
|
||||||
|
|
||||||
|
await core.query(
|
||||||
|
`INSERT INTO ${PRESENCE} (server_id, steam_id, name, sleeping, connected_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ${p.connectedAt ? 'FROM_UNIXTIME(? / 1000)' : 'NULL'}, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name), sleeping = VALUES(sleeping), updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
p.connectedAt
|
||||||
|
? [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0, p.connectedAt]
|
||||||
|
: [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes raw events older than `days`. Totals are never touched — that is the point of them. */
|
||||||
|
async function pruneEvents(days) {
|
||||||
|
if (!(days > 0)) return 0
|
||||||
|
|
||||||
|
const res = await core.query(
|
||||||
|
`DELETE FROM ${EVENTS} WHERE created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? DAY)`,
|
||||||
|
[days],
|
||||||
|
)
|
||||||
|
return (res && res.affectedRows) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent events, newest first, restricted to `kinds`.
|
||||||
|
*
|
||||||
|
* **`kinds` is never optional.** A default of "all kinds" is one forgotten
|
||||||
|
* argument away from publishing an IP address, so the caller is made to say it
|
||||||
|
* every time; `events.model.js` builds the list from the catalogue's allowlist
|
||||||
|
* and an empty list answers with no rows rather than with everything.
|
||||||
|
*/
|
||||||
|
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||||
|
if (!Array.isArray(kinds) || kinds.length === 0) return []
|
||||||
|
|
||||||
|
const holes = kinds.map(() => '?').join(', ')
|
||||||
|
const params = [serverId, ...kinds]
|
||||||
|
|
||||||
|
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
|
||||||
|
FROM ${EVENTS}
|
||||||
|
WHERE server_id = ? AND kind IN (${holes})`
|
||||||
|
|
||||||
|
if (wipeId) {
|
||||||
|
sql += ' AND wipe_id = ?'
|
||||||
|
params.push(wipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY id DESC LIMIT ?'
|
||||||
|
params.push(limit)
|
||||||
|
|
||||||
|
return core.query(sql, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
|
||||||
|
*
|
||||||
|
* All-time is a SUM over the per-wipe rows rather than a separate set of
|
||||||
|
* counters, which is what makes it impossible for the two to disagree — there
|
||||||
|
* is only ever one number, added up differently.
|
||||||
|
*/
|
||||||
|
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
|
||||||
|
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||||
|
|
||||||
|
const params = [serverId]
|
||||||
|
let where = 's.server_id = ?'
|
||||||
|
|
||||||
|
if (wipeId) {
|
||||||
|
where += ' AND s.wipe_id = ?'
|
||||||
|
params.push(wipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(limit)
|
||||||
|
|
||||||
|
return core.query(
|
||||||
|
`SELECT s.steam_id AS steamId,
|
||||||
|
p.name AS name,
|
||||||
|
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
|
||||||
|
FROM ${STATS} s
|
||||||
|
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
|
||||||
|
WHERE ${where}
|
||||||
|
GROUP BY s.steam_id, p.name
|
||||||
|
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWipes(serverId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT wipe_id AS wipeId, save_created_at AS saveCreatedAt,
|
||||||
|
first_seen AS firstSeen, last_seen AS lastSeen
|
||||||
|
FROM ${WIPES}
|
||||||
|
WHERE server_id = ?
|
||||||
|
ORDER BY wipe_id DESC`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function presenceFor(serverId) {
|
||||||
|
return core.query(
|
||||||
|
`SELECT steam_id AS steamId, name, sleeping, connected_at AS connectedAt
|
||||||
|
FROM ${PRESENCE}
|
||||||
|
WHERE server_id = ?
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
[serverId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCursor,
|
||||||
|
setCursor,
|
||||||
|
insertEvent,
|
||||||
|
touchWipe,
|
||||||
|
touchPlayer,
|
||||||
|
addStats,
|
||||||
|
addGathered,
|
||||||
|
replacePresence,
|
||||||
|
pruneEvents,
|
||||||
|
recentEvents,
|
||||||
|
leaderboard,
|
||||||
|
listWipes,
|
||||||
|
presenceFor,
|
||||||
|
}
|
||||||
162
server/model/events/events.model.js
Normal file
162
server/model/events/events.model.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// ── The read path's logic ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Everything that decides WHAT a caller gets, separated from the SQL that
|
||||||
|
// fetches it, so this file can be tested with no database and `events.db.js` has
|
||||||
|
// no branching to test.
|
||||||
|
//
|
||||||
|
// The decision that matters here is not a business rule, it is a boundary: what
|
||||||
|
// a signed-out visitor may see. Protocol 2 carries IP addresses and player
|
||||||
|
// reports, and the only thing standing between them and a public page is
|
||||||
|
// `catalogue.js`'s allowlist and the fact that **every read on this file takes an
|
||||||
|
// explicit viewer**. There is no default, because a default is what a caller
|
||||||
|
// gets when they forget — and the safe value is never the one that is easier to
|
||||||
|
// type.
|
||||||
|
|
||||||
|
const catalogue = require('../../catalogue')
|
||||||
|
const db = require('./events.db')
|
||||||
|
|
||||||
|
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||||
|
const MAX_LIMIT = 200
|
||||||
|
|
||||||
|
function boundedLimit(requested, fallback = 50) {
|
||||||
|
const n = Number(requested)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return fallback
|
||||||
|
return Math.min(Math.trunc(n), MAX_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a `kind` query parameter into a list.
|
||||||
|
*
|
||||||
|
* Accepts `?kind=player.death` and `?kind=player.death,player.chat`, and answers
|
||||||
|
* `null` for anything empty — which means "whatever this viewer may see" rather
|
||||||
|
* than "nothing", and is then narrowed by the catalogue.
|
||||||
|
*/
|
||||||
|
function parseKinds(raw) {
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const list = String(raw)
|
||||||
|
.split(',')
|
||||||
|
.map((k) => k.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
return list.length > 0 ? list : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent events for one server, already narrowed to what this viewer may see.
|
||||||
|
*
|
||||||
|
* **`admin` is a parameter, not a default.** A route that forgets it gets the
|
||||||
|
* public list, which is the direction it is safe to be wrong in. And a kind the
|
||||||
|
* caller asked for that they may not see is dropped silently rather than
|
||||||
|
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||||
|
* thing to leak and a free one to avoid.
|
||||||
|
*/
|
||||||
|
async function recent({ serverId, admin = false, kind = null, wipeId = null, limit }) {
|
||||||
|
const kinds = catalogue.kindsFor({ admin, requested: parseKinds(kind) })
|
||||||
|
|
||||||
|
// Every requested kind was refused. Answering with an empty list is right —
|
||||||
|
// the events they asked for are, as far as they are concerned, not there.
|
||||||
|
if (kinds.length === 0) return []
|
||||||
|
|
||||||
|
const rows = await db.recentEvents({
|
||||||
|
serverId,
|
||||||
|
kinds,
|
||||||
|
wipeId,
|
||||||
|
limit: boundedLimit(limit),
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows.map(shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored row as an API object.
|
||||||
|
*
|
||||||
|
* `raw` comes back from the database as text and is parsed here rather than in
|
||||||
|
* the db layer, because a row whose JSON will not parse is a reporting problem
|
||||||
|
* and not a query problem: it answers with the envelope it does know and an
|
||||||
|
* empty body, instead of failing a whole page over one bad row.
|
||||||
|
*/
|
||||||
|
function shape(row) {
|
||||||
|
let frame = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw || {}
|
||||||
|
} catch {
|
||||||
|
frame = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
kind: row.kind,
|
||||||
|
t: Number(row.t),
|
||||||
|
wipeId: row.wipeId || null,
|
||||||
|
steamId: row.steamId || null,
|
||||||
|
frame,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard for a server, per wipe or all-time.
|
||||||
|
*
|
||||||
|
* All-time is the same rows summed differently rather than a second set of
|
||||||
|
* counters, so the two can never disagree — which is the whole reason R12's
|
||||||
|
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||||
|
*/
|
||||||
|
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
|
||||||
|
const rows = await db.leaderboard({
|
||||||
|
serverId,
|
||||||
|
wipeId,
|
||||||
|
sort,
|
||||||
|
limit: boundedLimit(limit, 25),
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
steamId: r.steamId,
|
||||||
|
name: r.name || null,
|
||||||
|
kills: Number(r.kills) || 0,
|
||||||
|
deaths: Number(r.deaths) || 0,
|
||||||
|
npcKills: Number(r.npcKills) || 0,
|
||||||
|
structures: Number(r.structures) || 0,
|
||||||
|
playtimeSec: Number(r.playtimeSec) || 0,
|
||||||
|
lastSeen: r.lastSeen || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every wipe this server has had, newest first.
|
||||||
|
*
|
||||||
|
* The list is what makes the per-wipe view navigable, and it is also the proof
|
||||||
|
* R12 asks for: a wipe that ended is still here, with its stats still attached.
|
||||||
|
*/
|
||||||
|
async function wipes(serverId) {
|
||||||
|
const rows = await db.listWipes(serverId)
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
wipeId: r.wipeId,
|
||||||
|
saveCreatedAt: r.saveCreatedAt || null,
|
||||||
|
firstSeen: r.firstSeen,
|
||||||
|
lastSeen: r.lastSeen,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is on the server right now.
|
||||||
|
*
|
||||||
|
* Read from the presence board rather than counted from connect and disconnect
|
||||||
|
* events: the board is re-sent on every bridge connect and every minute, so it
|
||||||
|
* is right even after this module has missed something. Counting transitions
|
||||||
|
* instead would drift, and drift in exactly the direction people notice —
|
||||||
|
* players who never left.
|
||||||
|
*/
|
||||||
|
async function online(serverId) {
|
||||||
|
const rows = await db.presenceFor(serverId)
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
steamId: r.steamId,
|
||||||
|
name: r.name || null,
|
||||||
|
sleeping: Boolean(r.sleeping),
|
||||||
|
connectedAt: r.connectedAt || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }
|
||||||
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} | ||||||