From 49d0c1bd11bb3b8263fe41c429e512ecb7df5560 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 03:10:03 -0500 Subject: [PATCH] Add shard activity feed + admin live feed; fix public-feed leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front ends for the rest of the sidecar data, plus a security fix the live data surfaced. - lib/shardEvents.js: shared describe()/category/label for every event kind (sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds). - Public /site/shard/activity (ShardActivity): the full event log with category filter tabs and a live tail (history + SSE merged, de-duped). Linked from the Shard page. Shard page now reuses the shared describe(). - Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to the admin SSE channel — shows every kind incl. audit/cheat/login attempts. useShardFeed generalized to take a stream url; api.adminShardStreamUrl added. Security fix: GET /public/shard/feed now restricts to the public-safe kind allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned whatever was logged — including audit.* / cheat.* / link.request. Those are still stored for the admin channel but never served publicly (verified: a public request for audit.command returns 0 rows). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- client/src/App.jsx | 2 + client/src/api/client.js | 6 +- client/src/lib/shardEvents.js | 86 +++++++++++++++++++ client/src/lib/useShardFeed.js | 7 +- client/src/routes/admin/views/ShardAdmin.jsx | 35 ++++++++ client/src/routes/public/Shard.jsx | 46 +++------- client/src/routes/public/ShardActivity.jsx | 81 +++++++++++++++++ .../src/model/shardEvents/shardEvents.db.js | 14 ++- .../model/shardEvents/shardEvents.model.js | 7 +- .../src/router/v1/public/shard.controller.js | 13 ++- 10 files changed, 249 insertions(+), 48 deletions(-) create mode 100644 client/src/lib/shardEvents.js create mode 100644 client/src/routes/public/ShardActivity.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 5963be8..e7e98bf 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,6 +18,7 @@ import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' import Shard from './routes/public/Shard.jsx' import ShardChar from './routes/public/ShardChar.jsx' +import ShardActivity from './routes/public/ShardActivity.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -75,6 +76,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 34f9409..b2599c2 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -96,9 +96,11 @@ export const api = { idoc: () => req('/public/shard/idoc'), char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`), }, - // Full path (incl. /api/v1) for the browser EventSource — the req() wrapper is - // fetch-only, so SSE subscribers build the URL from here. + // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is + // fetch-only, so SSE subscribers build the URL from here. The admin stream + // carries every kind (incl. audit/cheat) and needs the staff session cookie. shardStreamUrl: `${BASE}/public/shard/stream`, + adminShardStreamUrl: `${BASE}/admin/uo-link/stream`, // ----- admin ----- admin: { diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js new file mode 100644 index 0000000..45a4305 --- /dev/null +++ b/client/src/lib/shardEvents.js @@ -0,0 +1,86 @@ +// Shared formatting for shard events — used by the public Shard page, the +// Activity feed, and the admin live feed. One place decides how each kind reads +// and which category/badge it belongs to. + +function nameOf(who) { + if (!who) return 'Someone' + if (typeof who === 'string') return who + return who.name || who.acct || 'Someone' +} + +const n = (v) => Number(v || 0).toLocaleString() + +// A one-line human description of an event. Accepts either a stored event +// (with .payload) or a raw live frame (fields at top level). +export function describe(ev) { + const p = ev.payload || ev + switch (ev.kind) { + case 'vendor.sale': + return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp` + case 'player.death': + return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}` + case 'player.murdered': + return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}` + case 'mob.killed': + return `${nameOf(p.killer)} killed ${nameOf(p.killed)}` + case 'skill.gain': + return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}` + case 'fame.change': + return `${nameOf(p.who)}’s fame changed to ${n(p.new)}` + case 'karma.change': + return `${nameOf(p.who)}’s karma changed to ${n(p.new)}` + case 'quest.complete': + return `${nameOf(p.who)} completed “${p.quest}”` + case 'house.decay': + return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}` + case 'mob.login': + return `${nameOf(p.who)} entered the world` + case 'mob.logout': + return `${nameOf(p.who)} left the world` + case 'economy.supply': + return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts` + case 'server.hello': + return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles` + case 'server.shutdown': + return 'Shard shut down' + case 'server.crashed': + return `Shard crashed${p.error ? `: ${p.error}` : ''}` + // Staff / sensitive (admin channel only) + case 'audit.set': + return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})` + case 'audit.command': + return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}` + case 'cheat.fastwalk': + return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}` + case 'account.login.attempt': + return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}` + case 'gold.change': + return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}` + default: + return ev.kind + } +} + +// Category grouping for the filter tabs. +export const CATEGORIES = [ + { id: 'all', label: 'All', kinds: null }, + { id: 'sales', label: 'Vendor sales', kinds: ['vendor.sale'] }, + { id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] }, + { id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] }, + { id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] }, +] + +const CATEGORY_OF = (() => { + const m = {} + for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id + return m +})() + +export function categoryOf(kind) { + return CATEGORY_OF[kind] || 'other' +} + +// Short badge label for a kind (the part after the dot, title-cased-ish). +export function kindLabel(kind) { + return String(kind || '').replace(/[._]/g, ' ') +} diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js index d4069e8..60e9558 100644 --- a/client/src/lib/useShardFeed.js +++ b/client/src/lib/useShardFeed.js @@ -10,19 +10,20 @@ import { api } from '../api/client.js' // `connected` flag is exposed for a small live/offline indicator. `filter` (a // Set of kinds, optional) limits which events are buffered. `max` caps the // buffer length. -export function useShardFeed({ filter, max = 40 } = {}) { +export function useShardFeed({ url, filter, max = 40 } = {}) { const [events, setEvents] = useState([]) const [connected, setConnected] = useState(false) // Keep the latest filter in a ref so re-renders don't tear down the stream. const filterRef = useRef(filter) filterRef.current = filter + const streamUrl = url || api.shardStreamUrl useEffect(() => { // EventSource isn't available during SSR / very old browsers — degrade to // "no live feed" rather than throwing. if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined - const es = new EventSource(api.shardStreamUrl, { withCredentials: true }) + const es = new EventSource(streamUrl, { withCredentials: true }) es.onopen = () => setConnected(true) es.onerror = () => setConnected(false) // EventSource will retry on its own @@ -47,7 +48,7 @@ export function useShardFeed({ filter, max = 40 } = {}) { return () => es.close() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [max]) + }, [max, streamUrl]) return { events, connected } } diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx index cc854aa..e59a405 100644 --- a/client/src/routes/admin/views/ShardAdmin.jsx +++ b/client/src/routes/admin/views/ShardAdmin.jsx @@ -1,7 +1,40 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useShardFeed } from '../../../lib/useShardFeed.js' +import { describe, kindLabel } from '../../../lib/shardEvents.js' +import { ago } from '../../../lib/format.js' import { api } from '../../../api/client.js' +// Full live feed from the admin SSE channel — every kind, incl. staff audit, +// cheat detection and login attempts that the public channel never carries. +function AdminLiveFeed() { + const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 }) + return ( +
+
+

Live feed (all events)

+ + + {connected ? 'Live' : 'Offline'} + +
+ {events.length === 0 ? ( +

Waiting for shard events…

+ ) : ( +
    + {events.map((e) => ( +
  • + {kindLabel(e.kind)} + {describe(e)} + {ago(e.t)} +
  • + ))} +
+ )} +
+ ) +} + // uo-link sidecar control panel. The auth token is write-only over this API — // stored encrypted, never returned — same convention as the Discord bot token. // Saving (re)starts the WS ingest client, so Enabled/URL/token changes take @@ -208,6 +241,8 @@ export default function ShardAdmin() { + + ) } diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx index 9917524..304765d 100644 --- a/client/src/routes/public/Shard.jsx +++ b/client/src/routes/public/Shard.jsx @@ -4,6 +4,7 @@ import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' import { useShardFeed } from '../../lib/useShardFeed.js' +import { describe } from '../../lib/shardEvents.js' import { ago } from '../../lib/format.js' import { api } from '../../api/client.js' @@ -42,38 +43,6 @@ function Stat({ value, label }) { ) } -// A one-line human description of a feed event. -function describe(ev) { - const p = ev.payload || ev - switch (ev.kind) { - case 'vendor.sale': - return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${Number(p.price || 0).toLocaleString()}gp` - case 'player.death': - return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}` - case 'player.murdered': - return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}` - case 'mob.killed': - return `${nameOf(p.killer)} killed ${nameOf(p.killed)}` - case 'house.decay': - return `${p.name || 'A house'} is now ${p.to || p.stage}` - case 'quest.complete': - return `${nameOf(p.who)} completed “${p.quest}”` - case 'skill.gain': - return `${nameOf(p.who)} gained ${p.skill}` - case 'mob.login': - return `${nameOf(p.who)} entered the world` - case 'mob.logout': - return `${nameOf(p.who)} left the world` - default: - return ev.kind - } -} -function nameOf(who) { - if (!who) return 'Someone' - if (typeof who === 'string') return who - return who.name || who.acct || 'Someone' -} - export default function Shard() { const { loading, error, data } = useAsync(() => Promise.all([ @@ -206,10 +175,15 @@ export default function Shard() {
Live feed
- - - {connected ? 'Live' : 'Offline'} - +
+ + View all activity → + + + + {connected ? 'Live' : 'Offline'} + +
{events.length === 0 ? (

diff --git a/client/src/routes/public/ShardActivity.jsx b/client/src/routes/public/ShardActivity.jsx new file mode 100644 index 0000000..3068d08 --- /dev/null +++ b/client/src/routes/public/ShardActivity.jsx @@ -0,0 +1,81 @@ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import PublicLayout from '../../components/PublicLayout.jsx' +import PageHeader from '../../components/PageHeader.jsx' +import { Loading, ErrorState } from '../../components/PageState.jsx' +import { useAsync } from '../../lib/useAsync.js' +import { useShardFeed } from '../../lib/useShardFeed.js' +import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js' +import { ago } from '../../lib/format.js' +import { api } from '../../api/client.js' + +// Public activity feed: the full shard event log, filterable by category, with a +// live tail that prepends new events as they happen. +export default function ShardActivity() { + const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 })) + const { events: live } = useShardFeed({ max: 60 }) + const [cat, setCat] = useState('all') + + // Merge the live tail with the loaded history, de-duped by kind+t, newest first. + const merged = useMemo(() => { + const seen = new Set() + const out = [] + for (const e of [...live, ...(data || [])]) { + const key = `${e.kind}-${e.t}` + if (seen.has(key)) continue + seen.add(key) + out.push(e) + } + return out.sort((a, b) => (b.t || 0) - (a.t || 0)) + }, [live, data]) + + const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat) + + return ( + +

+ +

+ ← Back to shard +

+ + {/* Category tabs */} +
+ {CATEGORIES.map((c) => ( + + ))} +
+ + {loading && } + {error && } + + {!loading && !error && ( + filtered.length === 0 ? ( +
+

Nothing here yet — events will appear as they happen in the world.

+
+ ) : ( +
    + {filtered.map((e) => ( +
  • + + {kindLabel(e.kind)} + + {describe(e)} + {ago(e.t)} +
  • + ))} +
+ ) + )} +
+ + ) +} diff --git a/server/src/model/shardEvents/shardEvents.db.js b/server/src/model/shardEvents/shardEvents.db.js index 792672a..6c8f864 100644 --- a/server/src/model/shardEvents/shardEvents.db.js +++ b/server/src/model/shardEvents/shardEvents.db.js @@ -12,8 +12,18 @@ async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) { return res.affectedRows > 0 } -// Recent events, newest first. Optional kind filter; limit is clamped by the model. -async function list({ kind, limit }) { +// Recent events, newest first. Filter by a single `kind`, or an allowlist of +// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak +// staff/sensitive kinds. limit is clamped by the model. +async function list({ kind, kinds, limit }) { + if (kinds && kinds.length) { + const placeholders = kinds.map(() => '?').join(', ') + return query( + `SELECT id, kind, t, boot_id, payload, created_at + FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`, + [...kinds, limit], + ) + } if (kind) { return query( `SELECT id, kind, t, boot_id, payload, created_at diff --git a/server/src/model/shardEvents/shardEvents.model.js b/server/src/model/shardEvents/shardEvents.model.js index 27818a0..1518204 100644 --- a/server/src/model/shardEvents/shardEvents.model.js +++ b/server/src/model/shardEvents/shardEvents.model.js @@ -35,9 +35,10 @@ function normalizeLimit(limit) { return Math.min(Math.floor(n), MAX_LIMIT) } -// Recent events, newest first. Each row's JSON payload is parsed back to an object. -async function list({ kind, limit } = {}) { - const rows = await db.list({ kind, limit: normalizeLimit(limit) }) +// Recent events, newest first. Each row's JSON payload is parsed back to an +// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind. +async function list({ kind, kinds, limit } = {}) { + const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) }) return rows.map((row) => ({ id: row.id, kind: row.kind, diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js index 14e72d7..28be23d 100644 --- a/server/src/router/v1/public/shard.controller.js +++ b/server/src/router/v1/public/shard.controller.js @@ -47,11 +47,20 @@ async function getStatus(req, res) { } } -// GET /public/shard/feed?kind=&limit= — recent notable events from the log. +// GET /public/shard/feed?kind=&limit= — recent notable events from the log, +// restricted to the public-safe allowlist so staff audit / cheat / link events +// (which are stored for the admin channel) can never leak to the public. async function getFeed(req, res) { try { const { kind, limit } = req.query - const events = await shardEvents.list({ kind, limit }) + let events + if (kind) { + // A specific kind is only served if it is itself public-safe. + if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([]) + events = await shardEvents.list({ kind, limit }) + } else { + events = await shardEvents.list({ kinds: [...broadcast.PUBLIC_KINDS], limit }) + } return res.json(events) } catch (err) { log.error('shard.getFeed', err)