fix(shard): restrict staff in-game location to admins/moderators #72
@@ -8,6 +8,7 @@ import { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
|
||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||
function Sparkline({ series }) {
|
||||
@@ -54,6 +55,11 @@ export default function Shard() {
|
||||
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||
)
|
||||
const { events, connected } = useShardFeed({ max: 30 })
|
||||
const { user } = useAuth()
|
||||
// Staff in-game location is privileged: only admins/moderators see it. Players
|
||||
// and the public see that staff are online but not where. The server enforces
|
||||
// this too (it omits the location fields entirely for non-privileged callers).
|
||||
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
const status = data?.status
|
||||
const online = status?.pluginConnected
|
||||
@@ -119,7 +125,7 @@ export default function Shard() {
|
||||
<PlayersOnline />
|
||||
</div>
|
||||
|
||||
{/* Staff online — linked staff accounts only, with location */}
|
||||
{/* Staff online — linked staff accounts only; location is admin/mod-only */}
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
Staff online
|
||||
@@ -134,9 +140,11 @@ export default function Shard() {
|
||||
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||
{p.name || p.serial}
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||
</span>
|
||||
{canSeeLocation && (
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -165,7 +165,7 @@ publicRouter.get(
|
||||
publicRouter.get(
|
||||
'/shard/online',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)'
|
||||
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
||||
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||
shard.getOnline,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcast = require('../../../utils/shardBroadcast')
|
||||
const auth = require('../../../utils/auth')
|
||||
|
||||
const log = require('../../../utils/logger')('public-shard')
|
||||
|
||||
@@ -70,12 +71,31 @@ async function getEconomy(req, res) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// STAFF website user (admin/editor/moderator). Everyone sees that a staff member
|
||||
// is online (name + serial); their in-game location (map + coordinates) is only
|
||||
// included for privileged viewers (admin/moderator) so it is never exposed to
|
||||
// players or the public via the network tab. Non-staff players are never listed.
|
||||
function canSeeStaffLocation(req) {
|
||||
const viewer = auth.getUserFromRequest(req)
|
||||
return !!viewer && (viewer.role === 'admin' || viewer.role === 'moderator')
|
||||
}
|
||||
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
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 })))
|
||||
const showLocation = canSeeStaffLocation(req)
|
||||
return res.json(
|
||||
rows.map((r) => {
|
||||
const entry = { serial: r.serial, name: r.name }
|
||||
if (showLocation) {
|
||||
entry.map = r.map
|
||||
entry.x = r.x
|
||||
entry.y = r.y
|
||||
entry.z = r.z
|
||||
}
|
||||
return entry
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('shard.getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
|
||||
105
server/test/publicShardOnline.test.js
Normal file
105
server/test/publicShardOnline.test.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// Staff-location visibility on GET /public/shard/online. The endpoint is
|
||||
// token-free, so it inspects the caller's session (getUserFromRequest) and only
|
||||
// includes each staff member's in-game location (map/x/y/z) for admins and
|
||||
// moderators. Players and the public still see who is online, but not where.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||||
// so any stray query fails fast instead of hanging. The model + auth are stubbed,
|
||||
// so the DB is never actually hit.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const ctrl = require('../src/router/v1/public/shard.controller')
|
||||
const shardState = require('../src/model/shardState/shardState.model')
|
||||
const auth = require('../src/utils/auth')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) {
|
||||
this.statusCode = c
|
||||
return this
|
||||
},
|
||||
json(b) {
|
||||
this.body = b
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// One online staff member with a location the model would return.
|
||||
const ONLINE_ROW = { serial: '0x1', name: 'Lady Mod', map: 'Felucca', x: 1495, y: 1628, z: 10 }
|
||||
|
||||
const originals = {
|
||||
listOnlineLinked: shardState.listOnlineLinked,
|
||||
getUserFromRequest: auth.getUserFromRequest,
|
||||
}
|
||||
afterEach(() => {
|
||||
shardState.listOnlineLinked = originals.listOnlineLinked
|
||||
auth.getUserFromRequest = originals.getUserFromRequest
|
||||
})
|
||||
|
||||
// Stub the model to return the staff member, and the session to the given viewer.
|
||||
function setup(viewer) {
|
||||
shardState.listOnlineLinked = async () => [ONLINE_ROW]
|
||||
auth.getUserFromRequest = () => viewer
|
||||
}
|
||||
|
||||
const LOCATION_KEYS = ['map', 'x', 'y', 'z']
|
||||
|
||||
for (const role of ['admin', 'moderator']) {
|
||||
test(`getOnline includes location for a ${role}`, async () => {
|
||||
setup({ id: 1, username: 'staff', role })
|
||||
const res = mockRes()
|
||||
await ctrl.getOnline({}, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.length, 1)
|
||||
const entry = res.body[0]
|
||||
assert.equal(entry.name, 'Lady Mod')
|
||||
assert.equal(entry.serial, '0x1')
|
||||
assert.equal(entry.map, 'Felucca')
|
||||
assert.equal(entry.x, 1495)
|
||||
assert.equal(entry.y, 1628)
|
||||
assert.equal(entry.z, 10)
|
||||
})
|
||||
}
|
||||
|
||||
test('getOnline omits location for a logged-in player', async () => {
|
||||
setup({ id: 2, username: 'joe', role: 'player' })
|
||||
const res = mockRes()
|
||||
await ctrl.getOnline({}, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
const entry = res.body[0]
|
||||
// Still shows they are online…
|
||||
assert.equal(entry.name, 'Lady Mod')
|
||||
assert.equal(entry.serial, '0x1')
|
||||
// …but the location fields are absent entirely (not null/placeholder).
|
||||
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
|
||||
})
|
||||
|
||||
test('getOnline omits location for an unauthenticated request', async () => {
|
||||
setup(null) // getUserFromRequest returns null for anon callers
|
||||
const res = mockRes()
|
||||
await ctrl.getOnline({}, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
const entry = res.body[0]
|
||||
assert.equal(entry.name, 'Lady Mod')
|
||||
assert.equal(entry.serial, '0x1')
|
||||
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
|
||||
})
|
||||
|
||||
// An editor is staff but not admin/moderator — they should not see location.
|
||||
test('getOnline omits location for an editor', async () => {
|
||||
setup({ id: 3, username: 'ed', role: 'editor' })
|
||||
const res = mockRes()
|
||||
await ctrl.getOnline({}, res)
|
||||
const entry = res.body[0]
|
||||
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
|
||||
})
|
||||
Reference in New Issue
Block a user