diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 36103a0..f741838 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -1,7 +1,8 @@ const express = require('express') -const { body } = require('express-validator') +const { body, param, query } = require('express-validator') const ctrl = require('./public.controller') +const shard = require('./shard.controller') const siteMode = require('../../../middleware/siteMode') const validate = require('../../../middleware/validate') const { contactLimiter } = require('../../../middleware/rateLimit') @@ -128,4 +129,66 @@ publicRouter.get( ctrl.getPage, ) +// ── Shard live data (uo-link) ────────────────────────────────────────────── +// Token-free, same-origin reads. The status/feed/economy/idoc endpoints read +// the site's own ingested data; /char round-trips the live shard (cached). Not +// site-mode gated — shard status is useful even during site maintenance. +publicRouter.get( + '/shard/status', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Shard connection state, online count and latest economy' + /* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */ + shard.getStatus, +) +publicRouter.get( + '/shard/feed', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Recent notable shard events (from the ingested log)' + // #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' } + /* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */ + query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }), + query('limit').optional().isInt({ min: 1, max: 1000 }), + validate, + shard.getFeed, +) +publicRouter.get( + '/shard/economy', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Gold-supply time series (oldest → newest)' + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' } + /* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */ + query('limit').optional().isInt({ min: 1, max: 1000 }), + validate, + shard.getEconomy, +) +publicRouter.get( + '/shard/idoc', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Houses currently in danger (IDOC)' + /* #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'] + // #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)' + // #swagger.description = 'text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.' + /* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */ + shard.stream, +) + module.exports = publicRouter diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js new file mode 100644 index 0000000..5b09c04 --- /dev/null +++ b/server/src/router/v1/public/shard.controller.js @@ -0,0 +1,121 @@ +// ── Public: shard live data ──────────────────────────────────────────────── +// +// Same-origin, token-free read endpoints backed by the data the WS ingest +// pipeline persists (shard_online / shard_events / shard_economy / shard_houses) +// plus a live character round-trip to the sidecar. The browser never sees the +// sidecar URL or token — every sidecar call is server-side (uoLinkClient). +// +// The stored-data endpoints are cheap DB reads. The live /char endpoint hits the +// running shard, so it is briefly cached and degrades gracefully: a 503 (shard +// restarting) surfaces as a retry-able banner rather than an error. + +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 { + const config = await uoLinkConfig.getSafe() + const [online, economy] = await Promise.all([ + shardState.onlineCount(), + shardState.latestEconomy(), + ]) + return res.json({ + enabled: config.enabled, + status: config.status, + pluginConnected: config.pluginConnected, + lastEventAt: config.lastEventAt, + onlineCount: online, + economy, + }) + } catch (err) { + log.error('shard.getStatus', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/feed?kind=&limit= — recent notable events from the log. +async function getFeed(req, res) { + try { + const { kind, limit } = req.query + const events = await shardEvents.list({ kind, limit }) + return res.json(events) + } catch (err) { + log.error('shard.getFeed', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/economy — gold-supply series, oldest → newest. +async function getEconomy(req, res) { + try { + return res.json(await shardState.listEconomy(req.query.limit)) + } catch (err) { + log.error('shard.getEconomy', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/idoc — houses currently in danger (stage IDOC). +async function getIdoc(req, res) { + try { + return res.json(await shardState.listIdoc()) + } catch (err) { + log.error('shard.getIdoc', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// 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, getIdoc, getChar, stream } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 9e1f6a0..801145f 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -36,6 +36,10 @@ "name": "Public", "description": "Unauthenticated site content (settings, posts, wiki, contact)" }, + { + "name": "Public · Shard", + "description": "Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)" + }, { "name": "Admin · Account", "description": "Self-service account security (2FA, linked identities)" @@ -1283,6 +1287,231 @@ } } }, + "/api/v1/public/shard/status": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Shard connection state, online count and latest economy", + "description": "", + "responses": { + "200": { + "description": "Shard status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardStatus" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/feed": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Recent notable shard events (from the ingested log)", + "description": "", + "parameters": [ + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter to a single event kind, e.g. vendor.sale." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max rows (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Events, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardEvent" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/economy": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Gold-supply time series (oldest → newest)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max samples (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Economy samples", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardEconomyPoint" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/idoc": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Houses currently in danger (IDOC)", + "description": "", + "responses": { + "200": { + "description": "IDOC houses", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/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": [ + "Public · Shard" + ], + "summary": "Live shard event stream (Server-Sent Events, public/safe kinds)", + "description": "text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.", + "responses": { + "200": { + "description": "An SSE stream (Content-Type: text/event-stream)." + } + } + } + }, "/api/v1/admin/account": { "get": { "tags": [ @@ -9274,6 +9503,501 @@ } } } + }, + "ShardStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Public shard status (GET /public/shard/status)." + }, + "properties": { + "type": "object", + "properties": { + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "connected" + }, + "description": { + "type": "string", + "example": "connected | reconnecting | disconnected | error" + } + } + }, + "pluginConnected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Is the shard link up right now?" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "lastEventAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "onlineCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + }, + "economy": { + "$ref": "#/components/schemas/ShardEconomyPoint" + } + } + } + } + }, + "ShardEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A logged shard event." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 4821 + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vendor.sale" + } + } + }, + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Event time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720195626 + } + } + }, + "bootId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "boot-abc123" + } + } + }, + "payload": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The full event object." + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "ShardEconomyPoint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "One gold-supply sample." + }, + "properties": { + "type": "object", + "properties": { + "accounts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 240 + } + } + }, + "gold": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1028983421 + } + } + }, + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Sample time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720000000 + } + } + } + } + } + } + }, + "ShardHouse": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A house at its current decay stage." + }, + "properties": { + "type": "object", + "properties": { + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x4004705F" + } + } + }, + "stage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "IDOC" + } + } + }, + "map": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "region": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "An Unnamed House" + } + } + }, + "ownerSerial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "ownerAcct": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "builtOn": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastRefreshed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "isIdoc": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "updatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 24bd8db..6df598f 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -46,6 +46,7 @@ const doc = { { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, + { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, @@ -506,6 +507,61 @@ const doc = { removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true }, }, }, + // ── uo-link shard data ────────────────────────────────────────────── + ShardStatus: { + type: 'object', + description: 'Public shard status (GET /public/shard/status).', + properties: { + enabled: { type: 'boolean', example: true }, + status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' }, + pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true }, + lastEventAt: { type: 'string', format: 'date-time', nullable: true }, + onlineCount: { type: 'integer', example: 12 }, + economy: { $ref: '#/components/schemas/ShardEconomyPoint' }, + }, + }, + ShardEvent: { + type: 'object', + description: 'A logged shard event.', + properties: { + id: { type: 'integer', example: 4821 }, + kind: { type: 'string', example: 'vendor.sale' }, + t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 }, + bootId: { type: 'string', nullable: true, example: 'boot-abc123' }, + payload: { type: 'object', additionalProperties: true, description: 'The full event object.' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + ShardEconomyPoint: { + type: 'object', + nullable: true, + description: 'One gold-supply sample.', + properties: { + accounts: { type: 'integer', nullable: true, example: 240 }, + gold: { type: 'integer', nullable: true, example: 1028983421 }, + t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 }, + }, + }, + ShardHouse: { + type: 'object', + description: 'A house at its current decay stage.', + properties: { + serial: { type: 'string', example: '0x4004705F' }, + stage: { type: 'string', example: 'IDOC' }, + map: { type: 'string', nullable: true, example: 'Trammel' }, + x: { type: 'integer', nullable: true }, + y: { type: 'integer', nullable: true }, + z: { type: 'integer', nullable: true }, + region: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true, example: 'An Unnamed House' }, + ownerSerial: { type: 'string', nullable: true }, + ownerAcct: { type: 'string', nullable: true }, + builtOn: { type: 'string', format: 'date-time', nullable: true }, + lastRefreshed: { type: 'string', format: 'date-time', nullable: true }, + isIdoc: { type: 'boolean', example: true }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, }, }, }