diff --git a/client/src/App.jsx b/client/src/App.jsx index e7e98bf..56caeee 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -17,7 +17,6 @@ import NewsletterIssue from './routes/public/NewsletterIssue.jsx' 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' @@ -77,7 +76,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> {/* CMS pages: top-level /:slug, matched only after the named routes diff --git a/client/src/api/client.js b/client/src/api/client.js index b2599c2..a880c5f 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -94,7 +94,6 @@ export const api = { economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`), online: () => req('/public/shard/online'), idoc: () => req('/public/shard/idoc'), - char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`), }, // 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 @@ -222,6 +221,8 @@ export const api = { accounts: () => req('/admin/shard/accounts'), roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`), vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), + char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), + sales: () => req('/admin/shard/sales'), }, // ----- auth providers / SSO config (admin only) ----- @@ -269,6 +270,8 @@ export const api = { accounts: () => req('/player/shard/accounts'), roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`), vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), + char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), + sales: () => req('/player/shard/sales'), }, }, } diff --git a/client/src/components/VendorSales.jsx b/client/src/components/VendorSales.jsx new file mode 100644 index 0000000..81cacb7 --- /dev/null +++ b/client/src/components/VendorSales.jsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' +import { ago } from '../lib/format.js' + +// Owner-private recent player-vendor sales. `fetchSales` is the scope method +// (api.player.shard.sales / api.admin.shard.sales) — the server only returns +// sales for accounts linked to the caller. +export default function VendorSales({ fetchSales }) { + const [sales, setSales] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let active = true + fetchSales() + .then((rows) => active && setSales(rows)) + .catch(() => active && setError('Could not load your vendor sales.')) + return () => { active = false } + }, [fetchSales]) + + if (error) return null + if (!sales) return null + + return ( +
+
Recent vendor sales
+ {sales.length === 0 ? ( +

No vendor sales recorded yet.

+ ) : ( +
    + {sales.map((s, i) => ( +
  • + + {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp + + {ago(s.t)} +
  • + ))} +
+ )} +
+ ) +} diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js index 45a4305..963cd71 100644 --- a/client/src/lib/shardEvents.js +++ b/client/src/lib/shardEvents.js @@ -62,9 +62,11 @@ export function describe(ev) { } // Category grouping for the filter tabs. +// Vendor sales are intentionally NOT a public category — they are owner-private +// (a linked player sees their own under the portal). The admin live feed still +// describes vendor.sale via describe() below. 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'] }, diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx index 1767a84..db54934 100644 --- a/client/src/routes/admin/views/AdminCharacter.jsx +++ b/client/src/routes/admin/views/AdminCharacter.jsx @@ -4,12 +4,13 @@ import CharacterSheet from '../../../components/CharacterSheet.jsx' import { useAsync } from '../../../lib/useAsync.js' import { api } from '../../../api/client.js' -// A staff member's character sheet inside the admin shell. Character data is -// public MMO data, so it uses the same cached public endpoint. +// A staff member's own character sheet inside the admin shell. Owner-checked — +// the endpoint only returns a sheet for a character on the caller's linked account. export default function AdminCharacter() { const { serial } = useParams() - const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial]) + const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial]) const restarting = error && error.status === 503 + const forbidden = error && error.status === 403 return (
@@ -20,7 +21,8 @@ export default function AdminCharacter() {

{loading && } {restarting && } - {error && !restarting && } + {forbidden && } + {error && !restarting && !forbidden && } {!loading && !error && data && }
) diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx index b2a7ec2..7167603 100644 --- a/client/src/routes/admin/views/AdminCharacters.jsx +++ b/client/src/routes/admin/views/AdminCharacters.jsx @@ -1,4 +1,5 @@ import GameAccounts from '../../../components/GameAccounts.jsx' +import VendorSales from '../../../components/VendorSales.jsx' import { api } from '../../../api/client.js' // Staff link their OWN in-game account and view their characters — the same @@ -10,6 +11,7 @@ export default function AdminCharacters() { Link your own game account to view your characters, stats, skills and vendors.

`/admin/characters/${serial}`} /> + ) } diff --git a/client/src/routes/player/PlayerCharacter.jsx b/client/src/routes/player/PlayerCharacter.jsx index 2ddf27e..3205a7a 100644 --- a/client/src/routes/player/PlayerCharacter.jsx +++ b/client/src/routes/player/PlayerCharacter.jsx @@ -4,12 +4,13 @@ import CharacterSheet from '../../components/CharacterSheet.jsx' import { useAsync } from '../../lib/useAsync.js' import { api } from '../../api/client.js' -// A player's character sheet inside the portal. Character data is public MMO -// data, so it uses the same cached public endpoint the site does. +// A player's character sheet inside the portal. Owner-checked: the endpoint only +// returns a sheet for a character on an account linked to the caller. export default function PlayerCharacter() { const { serial } = useParams() - const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial]) + const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial]) const restarting = error && error.status === 503 + const forbidden = error && error.status === 403 return (
@@ -20,7 +21,8 @@ export default function PlayerCharacter() {

{loading && } {restarting && } - {error && !restarting && } + {forbidden && } + {error && !restarting && !forbidden && } {!loading && !error && data && }
) diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx index bc48dee..65617b7 100644 --- a/client/src/routes/player/PlayerCharacters.jsx +++ b/client/src/routes/player/PlayerCharacters.jsx @@ -1,13 +1,16 @@ import GameAccounts from '../../components/GameAccounts.jsx' +import VendorSales from '../../components/VendorSales.jsx' import { api } from '../../api/client.js' // The logged-in player's characters. Shows the link prompt when no game account -// is linked, otherwise their characters grouped by account (shared component). +// is linked, otherwise their characters grouped by account (shared component), +// plus their own recent vendor sales. export default function PlayerCharacters() { return (

Your characters

`/player/char/${serial}`} /> +
) } diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx index 304765d..1f6ea5c 100644 --- a/client/src/routes/public/Shard.jsx +++ b/client/src/routes/public/Shard.jsx @@ -47,11 +47,10 @@ export default function Shard() { const { loading, error, data } = useAsync(() => Promise.all([ api.shard.status(), - api.shard.feed({ kind: 'vendor.sale', limit: 8 }), api.shard.idoc(), api.shard.economy(60), api.shard.online(), - ]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })), + ]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })), ) const { events, connected } = useShardFeed({ max: 30 }) @@ -115,26 +114,25 @@ export default function Shard() { - {/* Online now */} + {/* Staff online — linked staff accounts only, with location */}
- Online now + Staff online
{(!data.online || data.online.length === 0) ? ( -

No one is online right now.

+

No staff are online right now.

) : ( -
+
{data.online.map((p) => ( - - - {p.name || p.serial} - {p.map && · {p.map}} - +
+ + + {p.name || p.serial} + + + {p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''} + +
))}
)} @@ -150,13 +148,7 @@ export default function Shard() {
)} -
- {/* Recent vendor sales */} - ({ id: s.id, text: describe(s), when: s.t }))} - /> +
{/* Latest IDOC */} api.shard.char(serial), [serial]) - - const restarting = error && error.status === 503 - const notFound = error && error.status === 404 - - return ( - -
- - -

- - ← Back to shard - -

- - {loading && } - {restarting && } - {notFound && } - {error && !restarting && !notFound && } - {!loading && !error && data && } -
-
- ) -} diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js index 0e35276..74384f7 100644 --- a/server/src/model/shardState/shardState.db.js +++ b/server/src/model/shardState/shardState.db.js @@ -33,6 +33,26 @@ async function countOnline() { const listOnline = () => query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`) +// Staff roles whose online presence is shown on the public Shard page. Players +// who link an account are NOT surfaced publicly — only staff opt into visibility +// by virtue of being staff. +const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator'] + +// Online players whose game account is linked to a STAFF website user. Joined +// against shard_account_links (not the sidecar-supplied web_id) so a link takes +// effect immediately, regardless of whether the player has re-logged since +// linking, then through to users so only staff roles are surfaced publicly. +const listOnlineLinked = () => + query( + `SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')} + FROM shard_online o + JOIN shard_account_links l ON l.account = o.acct + JOIN users u ON u.id = l.user_id + WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')}) + ORDER BY o.name ASC`, + PUBLIC_ONLINE_ROLES, + ) + // ── Economy supply series ──────────────────────────────────────────────── const insertEconomy = ({ accounts, gold, t }) => query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [ @@ -75,6 +95,7 @@ module.exports = { clearOnline, countOnline, listOnline, + listOnlineLinked, insertEconomy, listEconomy, latestEconomy, diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js index bee0b96..87e31c4 100644 --- a/server/src/model/shardState/shardState.model.js +++ b/server/src/model/shardState/shardState.model.js @@ -44,6 +44,35 @@ const setOffline = (serial) => db.removeOnline(serial) const clearOnline = () => db.clearOnline() const onlineCount = () => db.countOnline() +function shapeOnline(r) { + return { + serial: r.serial, + name: r.name, + acct: r.acct, + webId: r.web_id, + map: r.map, + x: r.x, + y: r.y, + z: r.z, + hits: r.hits, + hitsMax: r.hits_max, + mana: r.mana, + manaMax: r.mana_max, + stam: r.stam, + stamMax: r.stam_max, + str: r.str, + dex: r.dex, + int: r.int, + updatedAt: r.updated_at, + } +} + +// Only players whose account is linked to a website user (opt-in visibility). +async function listOnlineLinked() { + const rows = await db.listOnlineLinked() + return rows.map(shapeOnline) +} + async function listOnline() { const rows = await db.listOnline() return rows.map((r) => ({ @@ -133,6 +162,7 @@ module.exports = { clearOnline, onlineCount, listOnline, + listOnlineLinked, addEconomySample, listEconomy, latestEconomy, diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 3134e0a..cab78a4 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -138,7 +138,7 @@ adminRouter.get( adminRouter.get( '/shard/roster/:account', // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Character roster for a linked account (self)' + // #swagger.summary = 'Character roster for an account (self; admins: any account)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' } /* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ @@ -150,7 +150,7 @@ adminRouter.get( adminRouter.get( '/shard/vendors/:account', // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Player vendors for a linked account (self)' + // #swagger.summary = 'Player vendors for an account (self; admins: any account)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' } /* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ @@ -159,6 +159,26 @@ adminRouter.get( validate, selfShard.vendors, ) +adminRouter.get( + '/shard/char/:serial', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Character sheet (self-linked characters; admins: any character)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' } + /* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('serial').matches(/^0x[0-9a-fA-F]+$/), + validate, + selfShard.getChar, +) +adminRouter.get( + '/shard/sales', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + selfShard.getSales, +) // ── Image uploads (screenshots/gallery) ─────────────────────────────── const UPLOAD_DIR = diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js index 7af0cdb..80da8b7 100644 --- a/server/src/router/v1/player/player.routes.js +++ b/server/src/router/v1/player/player.routes.js @@ -182,5 +182,26 @@ playerRouter.get( validate, shard.vendors, ) +playerRouter.get( + '/shard/char/:serial', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Character sheet — only for a character on the caller’s linked account' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' } + /* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('serial').matches(/^0x[0-9a-fA-F]+$/), + validate, + shard.getChar, +) +playerRouter.get( + '/shard/sales', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + shard.getSales, +) module.exports = playerRouter diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js index c6c230f..0f9359c 100644 --- a/server/src/router/v1/player/shard.controller.js +++ b/server/src/router/v1/player/shard.controller.js @@ -9,10 +9,13 @@ const uoLinkClient = require('../../../utils/uoLinkClient') const shardLinks = require('../../../model/shardLinks/shardLinks.model') +const shardEvents = require('../../../model/shardEvents/shardEvents.model') const activity = require('../../../model/activity/activity.model') const log = require('../../../utils/logger')('player-shard') +const SERIAL_RE = /^0x[0-9a-fA-F]+$/ + // POST /player/shard/link — confirm an in-game link code. async function link(req, res) { const { code } = req.body @@ -51,12 +54,18 @@ async function listAccounts(req, res) { } } +// Admins may view any character's data; everyone else is limited to accounts +// they have personally linked. The same handlers back /player/shard (role +// `player`, never admin) and /admin/shard (staff), so this bypass only ever +// widens access for genuine admins. +const isAdmin = (req) => req.user && req.user.role === 'admin' + // Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the // uoLinkClient method to call with the account. async function ownedRoundTrip(req, res, fetcher, label) { const { account } = req.params try { - const owns = await shardLinks.ownsAccount(account, req.user.id) + const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id)) if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' }) const result = await fetcher(account) @@ -78,4 +87,58 @@ const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'r // GET /player/shard/vendors/:account — player vendors on a linked account. const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors') -module.exports = { link, listAccounts, roster, vendors } +// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's +// account is linked to the caller. The sidecar returns the owning account in the +// profile, which we check against the caller's links before returning anything. +async function getChar(req, res) { + const { serial } = req.params + if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' }) + try { + const result = await uoLinkClient.getCharBySerial(serial) + if (result.ok) { + // Admins see any character; others only characters on an account they linked. + if (!isAdmin(req)) { + const acct = result.data && result.data.acct + const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false + if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' }) + } + return res.json(result.data) + } + if (result.status === 404) return res.status(404).json({ message: 'Character not found.' }) + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The game server is restarting — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error('player.shard.getChar', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/sales — recent player-vendor sales for the caller's linked +// accounts only (as seller/owner). Read from the site's own event log. +async function getSales(req, res) { + try { + const links = await shardLinks.listForUser(req.user.id) + const accounts = new Set(links.map((l) => l.account)) + if (accounts.size === 0) return res.json([]) + const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 }) + const mine = events + .filter((e) => e.payload && accounts.has(e.payload.ownerAcct)) + .slice(0, 50) + .map((e) => ({ + t: e.t, + itemType: e.payload.itemType, + amount: e.payload.amount, + price: e.payload.price, + commission: e.payload.commission, + ownerAcct: e.payload.ownerAcct, + })) + return res.json(mine) + } catch (err) { + log.error('player.shard.getSales', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { link, listAccounts, roster, vendors, getChar, getSales } diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 3f77764..992a7a1 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -165,7 +165,7 @@ publicRouter.get( publicRouter.get( '/shard/online', // #swagger.tags = ['Public · Shard'] - // #swagger.summary = 'Players online now (name + serial + map only)' + // #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)' /* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */ shard.getOnline, ) @@ -176,19 +176,6 @@ publicRouter.get( /* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ shard.getIdoc, ) -publicRouter.get( - '/shard/char/:serial', - // #swagger.tags = ['Public · Shard'] - // #swagger.summary = 'Live character sheet by serial (cached; degrades on shard restart)' - // #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' } - /* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[400] = { description: 'Invalid serial', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Character not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[503] = { description: 'Shard restarting — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('serial').matches(/^0x[0-9a-fA-F]+$/), - validate, - shard.getChar, -) publicRouter.get( '/shard/stream', // #swagger.tags = ['Public · Shard'] diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js index 28be23d..a62b8f7 100644 --- a/server/src/router/v1/public/shard.controller.js +++ b/server/src/router/v1/public/shard.controller.js @@ -12,19 +12,10 @@ const shardEvents = require('../../../model/shardEvents/shardEvents.model') const shardState = require('../../../model/shardState/shardState.model') const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model') -const uoLinkClient = require('../../../utils/uoLinkClient') const broadcast = require('../../../utils/shardBroadcast') const log = require('../../../utils/logger')('public-shard') -// Serials are opaque hex keys like "0x24C" — validate before hitting the sidecar. -const SERIAL_RE = /^0x[0-9a-fA-F]+$/ - -// Tiny in-memory cache for live character sheets (the sidecar warns these hit the -// live shard, so cache them). Keyed by serial; short TTL. -const CHAR_TTL_MS = 20000 -const charCache = new Map() - // GET /public/shard/status — connection state + online count + latest economy. async function getStatus(req, res) { try { @@ -78,13 +69,13 @@ async function getEconomy(req, res) { } } -// GET /public/shard/online — who is online now (redacted: name + serial + map, -// no coordinates, vitals or account). Feeds the public "online now" list, which -// links to the public character sheet. +// GET /public/shard/online — players online now whose account is linked to a +// STAFF website user (admin/editor/moderator). Shows name + location (map + +// coordinates); no vitals or account. Non-staff players are never listed. async function getOnline(req, res) { try { - const rows = await shardState.listOnline() - return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map }))) + const rows = await shardState.listOnlineLinked() + return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map, x: r.x, y: r.y, z: r.z }))) } catch (err) { log.error('shard.getOnline', err) return res.status(500).json({ message: 'Internal Server Error' }) @@ -101,43 +92,9 @@ async function getIdoc(req, res) { } } -// GET /public/shard/char/:serial — live character sheet (cached briefly). A 503 -// from the sidecar means the shard is restarting: report it as such so the UI -// can show a retry banner instead of an error. -async function getChar(req, res) { - const { serial } = req.params - if (!SERIAL_RE.test(serial)) { - return res.status(400).json({ message: 'Invalid serial.' }) - } - - const cached = charCache.get(serial) - if (cached && Date.now() - cached.at < CHAR_TTL_MS) { - return res.json(cached.data) - } - - try { - const result = await uoLinkClient.getCharBySerial(serial) - if (result.ok) { - charCache.set(serial, { at: Date.now(), data: result.data }) - return res.json(result.data) - } - if (result.status === 404) return res.status(404).json({ message: 'Character not found.' }) - if (result.status === 503) { - // Serve a stale cache if we have one; otherwise the restart banner. - if (cached) return res.json(cached.data) - return res.status(503).json({ message: 'The game server is restarting — try again shortly.' }) - } - if (result.status === 0) return res.status(503).json({ message: 'Shard data is unavailable right now.' }) - return res.status(502).json({ message: 'Could not reach the shard.' }) - } catch (err) { - log.error('shard.getChar', err) - return res.status(500).json({ message: 'Internal Server Error' }) - } -} - // GET /public/shard/stream — public live-event SSE channel (safe kinds only). function stream(req, res) { broadcast.subscribe(req, res, 'public') } -module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChar, stream } +module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream } diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js index 13f740c..c12637f 100644 --- a/server/src/utils/shardBroadcast.js +++ b/server/src/utils/shardBroadcast.js @@ -15,9 +15,10 @@ const log = require('./logger')('shard-broadcast') -// Kinds safe to expose to unauthenticated browsers. +// 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([ - 'vendor.sale', 'player.death', 'player.murdered', 'mob.killed', diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 6978c0f..a9090cd 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1415,7 +1415,7 @@ "tags": [ "Public · Shard" ], - "summary": "Players online now (name + serial + map only)", + "summary": "Staff online now (linked staff accounts; name + serial + map only)", "description": "", "responses": { "200": { @@ -1464,75 +1464,6 @@ } } }, - "/api/v1/public/shard/char/{serial}": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Live character sheet by serial (cached; degrades on shard restart)", - "description": "", - "parameters": [ - { - "name": "serial", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Mobile serial, e.g. 0x24C." - } - ], - "responses": { - "200": { - "description": "Character profile", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Invalid serial", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Character not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Shard restarting — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, "/api/v1/public/shard/stream": { "get": { "tags": [ @@ -1984,7 +1915,7 @@ "tags": [ "Admin · Account" ], - "summary": "Character roster for a linked account (self)", + "summary": "Character roster for an account (self; admins: any account)", "description": "", "parameters": [ { @@ -2038,7 +1969,7 @@ "tags": [ "Admin · Account" ], - "summary": "Player vendors for a linked account (self)", + "summary": "Player vendors for an account (self; admins: any account)", "description": "", "parameters": [ { @@ -2087,6 +2018,107 @@ ] } }, + "/api/v1/admin/shard/char/{serial}": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Character sheet (self-linked characters; admins: any character)", + "description": "", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Mobile serial, e.g. 0x24C." + } + ], + "responses": { + "200": { + "description": "Character profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Character not on an account linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/sales": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Recent player-vendor sales for the caller’s linked accounts (self)", + "description": "", + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardVendorSale" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/dashboard": { "get": { "tags": [ @@ -7002,6 +7034,123 @@ } ] } + }, + "/api/v1/player/shard/char/{serial}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Character sheet — only for a character on the caller’s linked account", + "description": "", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Mobile serial, e.g. 0x24C." + } + ], + "responses": { + "200": { + "description": "Character profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Character not on an account linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/sales": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Recent player-vendor sales for the caller’s linked accounts", + "description": "", + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardVendorSale" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -10544,7 +10693,7 @@ }, "description": { "type": "string", - "example": "A player online now (redacted for the public list)." + "example": "A LINKED player online now (only accounts linked to a website user are listed)." }, "properties": { "type": "object", @@ -10591,6 +10740,161 @@ "example": "Trammel" } } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1402 + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1604 + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 0 + } + } + } + } + } + } + }, + "ShardVendorSale": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A player-vendor sale (visible only to the linked owner)." + }, + "properties": { + "type": "object", + "properties": { + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Sale time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720195626 + } + } + }, + "itemType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Longsword" + } + } + }, + "amount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "price": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 100 + } + } + }, + "commission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 5 + } + } + }, + "ownerAcct": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 0d245b8..a6838f8 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -546,11 +546,26 @@ const doc = { }, ShardOnlinePlayer: { type: 'object', - description: 'A player online now (redacted for the public list).', + description: 'A LINKED player online now (only accounts linked to a website user are listed).', properties: { serial: { type: 'string', example: '0x24C' }, name: { type: 'string', example: 'Darrow' }, map: { type: 'string', nullable: true, example: 'Trammel' }, + x: { type: 'integer', nullable: true, example: 1402 }, + y: { type: 'integer', nullable: true, example: 1604 }, + z: { type: 'integer', nullable: true, example: 0 }, + }, + }, + ShardVendorSale: { + type: 'object', + description: 'A player-vendor sale (visible only to the linked owner).', + properties: { + t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 }, + itemType: { type: 'string', example: 'Longsword' }, + amount: { type: 'integer', example: 1 }, + price: { type: 'integer', example: 100 }, + commission: { type: 'integer', nullable: true, example: 5 }, + ownerAcct: { type: 'string', example: 'whitlocktech' }, }, }, ShardHouse: {