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
145 lines
6.5 KiB
JavaScript
145 lines
6.5 KiB
JavaScript
// ── Player: game-account linking + reads ───────────────────────────────────
|
|
//
|
|
// The player-facing surface for the uo-link integration. A logged-in player
|
|
// runs [link in game, gets a one-time code, and enters it here — the server
|
|
// confirms it with the sidecar (which permanently tags the game account with the
|
|
// website user id) and mirrors the link locally. Roster/vendor reads are
|
|
// ownership-checked against that mirror so a player can only see accounts they
|
|
// have linked. The sidecar token stays server-side throughout.
|
|
|
|
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
|
|
try {
|
|
const result = await uoLinkClient.confirmLink(code, req.user.id)
|
|
|
|
if (result.ok && result.data && result.data.kind === 'link.ok') {
|
|
const account = result.data.account
|
|
await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null })
|
|
await activity.log({ req, action: 'uoLink.account.link', detail: { account } })
|
|
log.info('player linked game account', { user: req.user.username, account })
|
|
return res.json({ linked: true, account })
|
|
}
|
|
|
|
// Sidecar reports bad/expired codes as 400 link.error or 404.
|
|
if (result.status === 400 || result.status === 404) {
|
|
return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' })
|
|
}
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not confirm the link with the shard.' })
|
|
} catch (err) {
|
|
log.error('player.shard.link', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /player/shard/accounts — the caller's linked game accounts.
|
|
async function listAccounts(req, res) {
|
|
try {
|
|
return res.json(await shardLinks.listForUser(req.user.id))
|
|
} catch (err) {
|
|
log.error('player.shard.listAccounts', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// 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 = 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)
|
|
if (result.ok) return res.json(result.data)
|
|
if (result.status === 404) return res.status(404).json({ message: 'Not found.' })
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
|
} catch (err) {
|
|
log.error(`player.shard.${label}`, err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /player/shard/roster/:account — characters on a linked account.
|
|
const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster')
|
|
|
|
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
|
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, '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 }
|