Public "Online now" now lists only players whose game account is linked to a STAFF website user (admin/editor/moderator) — linked players are no longer exposed publicly with their name and location. listOnlineLinked joins through to users and filters on role; the section is relabeled "Staff online". Character/roster/vendor reads gain an admin bypass: admins may view any character's data, while players (and editor/moderator staff) stay limited to accounts they have personally linked. The bypass lives in the shared player controller and only ever widens access for genuine admins. Also finalizes the uo-link character/vendor front end (player + admin character sheets, VendorSales component, ShardChar removed) and regenerates swagger-output.json. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
118 lines
3.5 KiB
JavaScript
118 lines
3.5 KiB
JavaScript
// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
|
|
//
|
|
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
|
// never reach it, and the WS may be on another host). Instead the server ingests
|
|
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
|
|
// Events (plain HTTP — works through any reverse proxy).
|
|
//
|
|
// Two channels:
|
|
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
|
|
// no account-login attempts, no staff audit / cheat events.
|
|
// • admin — everything, including the sensitive kinds above.
|
|
//
|
|
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
|
// SSE route handlers call subscribe(req, res, channel).
|
|
|
|
const log = require('./logger')('shard-broadcast')
|
|
|
|
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
|
|
// deliberately NOT here — sales are owner-private (a linked player sees only
|
|
// their own, via /player/shard/sales).
|
|
const PUBLIC_KINDS = new Set([
|
|
'player.death',
|
|
'player.murdered',
|
|
'mob.killed',
|
|
'house.decay',
|
|
'quest.complete',
|
|
'skill.gain',
|
|
'fame.change',
|
|
'karma.change',
|
|
'mob.login',
|
|
'mob.logout',
|
|
'economy.supply',
|
|
'server.hello',
|
|
'server.shutdown',
|
|
'server.crashed',
|
|
])
|
|
|
|
// Open response streams per channel.
|
|
const clients = { public: new Set(), admin: new Set() }
|
|
|
|
const KEEPALIVE_MS = 25000
|
|
|
|
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
|
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
|
function subscribe(req, res, channel) {
|
|
const bucket = clients[channel]
|
|
if (!bucket) {
|
|
res.status(400).end()
|
|
return
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
|
|
})
|
|
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
|
res.write(': connected\n\n')
|
|
|
|
bucket.add(res)
|
|
|
|
const ping = setInterval(() => {
|
|
try {
|
|
res.write(': ping\n\n')
|
|
} catch {
|
|
/* write after close — cleanup below handles it */
|
|
}
|
|
}, KEEPALIVE_MS)
|
|
|
|
const cleanup = () => {
|
|
clearInterval(ping)
|
|
bucket.delete(res)
|
|
}
|
|
req.on('close', cleanup)
|
|
res.on('error', cleanup)
|
|
}
|
|
|
|
function writeTo(bucket, payload) {
|
|
for (const res of bucket) {
|
|
try {
|
|
res.write(payload)
|
|
} catch (err) {
|
|
log.warn('sse write failed; dropping client', { message: err.message })
|
|
bucket.delete(res)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fan an ingested event out to the admin channel (always) and the public
|
|
// channel (safe kinds only). A no-op when nobody is subscribed.
|
|
function broadcast(event) {
|
|
if (!event || !event.kind) return
|
|
const frame = `data: ${JSON.stringify(event)}\n\n`
|
|
if (clients.admin.size) writeTo(clients.admin, frame)
|
|
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
|
|
}
|
|
|
|
// Close every open stream (graceful shutdown).
|
|
function closeAll() {
|
|
for (const channel of Object.values(clients)) {
|
|
for (const res of channel) {
|
|
try {
|
|
res.end()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
channel.clear()
|
|
}
|
|
}
|
|
|
|
function stats() {
|
|
return { publicClients: clients.public.size, adminClients: clients.admin.size }
|
|
}
|
|
|
|
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }
|