Staff online
@@ -134,9 +140,11 @@ export default function Shard() {
{p.name || p.serial}
-
- {p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
-
+ {canSeeLocation && (
+
+ {p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
+
+ )}
))}
diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js
index e172df5..a11504e 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 = '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,
)
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 642e9a8..64fbdf1 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -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' })
diff --git a/server/test/publicShardOnline.test.js b/server/test/publicShardOnline.test.js
new file mode 100644
index 0000000..bbfa3e1
--- /dev/null
+++ b/server/test/publicShardOnline.test.js
@@ -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`)
+})