diff --git a/.env.example b/.env.example index 3a711a0..49682ec 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,15 @@ CLIENT_ORIGIN=http://localhost:5173 # longer rides the public listener, but an explicit deny rule is belt-and-braces. BOT_INTERNAL_URL=http://bot:4100 BOT_INTERNAL_KEY=change-me-to-a-long-random-string + +# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The +# website ingests its live event feed and proxies its read queries/commands +# (shard status, online players, player-vendor sales, IDOC houses, character +# sheets, account linking, town-crier). In production the sidecar + shard run on +# a DIFFERENT host from the website, so both URLs are configurable. The +# shared-secret auth token is NOT an env var — it is entered in the admin panel +# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot +# token). These URLs are just defaults; the admin can override them at runtime. +UOLINK_BASE_URL=http://127.0.0.1:8080 +UOLINK_WS_URL=ws://127.0.0.1:8080/ws +UOLINK_PROTOCOL=1 diff --git a/server/db/schema.sql b/server/db/schema.sql index 504260e..8563126 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -259,6 +259,36 @@ CREATE TABLE IF NOT EXISTS email_config ( CONSTRAINT chk_email_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ── uo-link sidecar ──────────────────────────────────────────────────────── +-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the +-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the +-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never +-- plaintext, and it is only decrypted server-side (to call the sidecar). It is +-- never returned to the admin UI — responses expose only `hasToken`. base_url is +-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because +-- in production the sidecar runs on a different host from the website. `status`/ +-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known +-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId +-- so a shard restart can be detected (and caches dropped). +CREATE TABLE IF NOT EXISTS uo_link_config ( + id INT PRIMARY KEY DEFAULT 1, + base_url VARCHAR(255) NULL, + ws_url VARCHAR(255) NULL, + auth_token_enc TEXT NULL, + protocol INT NOT NULL DEFAULT 1, + enabled TINYINT(1) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'disconnected', + status_detail VARCHAR(500) NULL, + plugin_connected TINYINT(1) NOT NULL DEFAULT 0, + last_event_at DATETIME NULL, + boot_id VARCHAR(64) NULL, + updated_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Discord bot moderation core (Phase 2). These tables are owned by the bot -- process (its own DB pool, bot/src/db.js) — the main server never reads or -- writes them. They live in the same physical database as everything else diff --git a/server/src/model/uoLinkConfig/uoLinkConfig.db.js b/server/src/model/uoLinkConfig/uoLinkConfig.db.js new file mode 100644 index 0000000..63f66be --- /dev/null +++ b/server/src/model/uoLinkConfig/uoLinkConfig.db.js @@ -0,0 +1,28 @@ +const { query } = require('../../utils/db') + +const COLS = + 'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at' + +// Singleton row (id = 1). Returns null until the admin saves it for the first time. +async function get() { + const rows = await query(`SELECT ${COLS} FROM uo_link_config WHERE id = 1 LIMIT 1`) + return rows[0] || null +} + +// Upsert the singleton row. `fields` are column values already prepared by the +// model (token pre-encrypted). Only the provided columns are written/updated. +async function upsert(fields) { + const cols = Object.keys(fields) + const vals = cols.map((c) => fields[c]) + const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ') + const placeholders = ['1', ...cols.map(() => '?')].join(', ') + const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ') + await query( + `INSERT INTO uo_link_config (${insertCols}) VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE ${updates}`, + vals, + ) + return get() +} + +module.exports = { get, upsert } diff --git a/server/src/model/uoLinkConfig/uoLinkConfig.model.js b/server/src/model/uoLinkConfig/uoLinkConfig.model.js new file mode 100644 index 0000000..5b3736e --- /dev/null +++ b/server/src/model/uoLinkConfig/uoLinkConfig.model.js @@ -0,0 +1,85 @@ +// uo-link sidecar connection config store. Mirrors botConfig/emailConfig: the DB +// layer only ever sees ciphertext, and only getWithToken() (used server-side to +// call the sidecar over REST/WS) decrypts it. The admin-facing getSafe() never +// includes the token — it exposes only `hasToken`. A blank `token` on save means +// "leave the existing token unchanged" (same convention as the other configs). + +const db = require('./uoLinkConfig.db') +const secretBox = require('../../utils/secretBox') + +const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1 + +function toSafe(row) { + if (!row) { + return { + baseUrl: process.env.UOLINK_BASE_URL || null, + wsUrl: process.env.UOLINK_WS_URL || null, + protocol: DEFAULT_PROTOCOL, + enabled: false, + hasToken: false, + status: 'disconnected', + statusDetail: null, + pluginConnected: false, + lastEventAt: null, + bootId: null, + } + } + return { + baseUrl: row.base_url || null, + wsUrl: row.ws_url || null, + protocol: row.protocol || DEFAULT_PROTOCOL, + enabled: Boolean(row.enabled), + hasToken: Boolean(row.auth_token_enc), + status: row.status || 'disconnected', + statusDetail: row.status_detail || null, + pluginConnected: Boolean(row.plugin_connected), + lastEventAt: row.last_event_at || null, + bootId: row.boot_id || null, + } +} + +async function getSafe() { + return toSafe(await db.get()) +} + +// Decrypted token included — server-side only (calling the sidecar's REST/WS +// API). Returns null when nothing has been saved yet. +async function getWithToken() { + const row = await db.get() + if (!row) return null + return { ...toSafe(row), token: row.auth_token_enc ? secretBox.decrypt(row.auth_token_enc) : null } +} + +// Save admin-supplied config. `token` undefined or '' means "leave the existing +// token unchanged" (same convention as botConfig.save). +async function save({ baseUrl, wsUrl, token, protocol, enabled, updatedBy }) { + const fields = {} + if (baseUrl !== undefined) fields.base_url = baseUrl + if (wsUrl !== undefined) fields.ws_url = wsUrl + if (token) fields.auth_token_enc = secretBox.encrypt(token) + if (protocol !== undefined) fields.protocol = protocol + if (enabled !== undefined) fields.enabled = enabled ? 1 : 0 + if (updatedBy !== undefined) fields.updated_by = updatedBy + const row = await db.upsert(fields) + return toSafe(row) +} + +// Mirror the sidecar's last-reported connection state into the DB so the admin +// panel has something to show between polls and the public status endpoint can +// read it without a live round-trip. +async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt, bootId }) { + const fields = {} + if (status !== undefined) fields.status = status + if (statusDetail !== undefined) fields.status_detail = statusDetail + if (pluginConnected !== undefined) fields.plugin_connected = pluginConnected ? 1 : 0 + // lastEventAt may arrive as an ISO string (e.g. "2026-07-10T22:08:27Z"); the + // mariadb DATETIME parser rejects the "T"/"Z", so hand it a real Date (same + // fix as botConfig.recordStatus's last_connected_at). + if (lastEventAt !== undefined) fields.last_event_at = lastEventAt ? new Date(lastEventAt) : null + if (bootId !== undefined) fields.boot_id = bootId + if (Object.keys(fields).length === 0) return getSafe() + const row = await db.upsert(fields) + return toSafe(row) +} + +module.exports = { getSafe, getWithToken, save, recordStatus } diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js new file mode 100644 index 0000000..b925ace --- /dev/null +++ b/server/src/utils/uoLinkClient.js @@ -0,0 +1,125 @@ +// ── uo-link sidecar REST client ──────────────────────────────────────────── +// +// Server-side HTTP client for the uo-link sidecar (the bridge to the ServUO +// shard). Same shape as botInternalClient: never throws — every call returns +// { ok, data, status, error } so an admin poll or a public page never 500s just +// because the sidecar/shard is down or restarting. +// +// The base URL + shared-secret token come from the DB-backed uoLinkConfig +// (admin-managed, encrypted at rest) — NOT env vars, and the token is NEVER sent +// to the browser. Every request carries `Authorization: Bearer ` and +// `X-UOLink-Version: ` so a protocol mismatch is caught (409) rather +// than mis-parsed. Config is cached for a few seconds to avoid decrypting the +// token on every call. + +const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') +const log = require('./logger')('uo-link-client') + +const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504 +const CONFIG_TTL_MS = 5000 + +let cachedConfig = null +let cachedAt = 0 + +// Read (and briefly cache) the connection config incl. decrypted token. +async function resolveConfig() { + const now = Date.now() + if (cachedConfig && now - cachedAt < CONFIG_TTL_MS) return cachedConfig + cachedConfig = await uoLinkConfig.getWithToken() + cachedAt = now + return cachedConfig +} + +// Drop the cache after a save so the next call picks up new URL/token immediately. +function invalidateConfig() { + cachedConfig = null + cachedAt = 0 +} + +// Core request. Returns { ok, data, status, error }. `ok` is true only on a 2xx +// with a parseable JSON body. Non-2xx responses still return their status + body +// so callers can distinguish 503 (shard restarting — transient) from 404. +async function call(path, { method = 'GET', body } = {}) { + const config = await resolveConfig() + if (!config || !config.baseUrl) { + return { ok: false, status: 0, error: 'uo-link is not configured' } + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const headers = { + 'Content-Type': 'application/json', + 'X-UOLink-Version': String(config.protocol || 1), + } + if (config.token) headers.Authorization = `Bearer ${config.token}` + + const res = await fetch(`${config.baseUrl}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + + let data = null + try { + data = await res.json() + } catch { + // Non-JSON (or empty) body — leave data null; status still reported. + } + + if (!res.ok) { + if (res.status === 401) log.warn('uo-link rejected auth token (401)', { path }) + if (res.status === 409) log.error('uo-link protocol mismatch (409)', { path, body: data }) + return { ok: false, status: res.status, data, error: `sidecar responded ${res.status}` } + } + return { ok: true, status: res.status, data } + } catch (err) { + log.warn('uo-link call failed', { path, message: err.message }) + return { ok: false, status: 0, error: err.message } + } finally { + clearTimeout(timeout) + } +} + +// ── Read queries ─────────────────────────────────────────────────────────── +// Liveness (no auth required by the sidecar, but we send it anyway). +const health = () => call('/health') +const getCharBySerial = (serial) => call(`/char/serial/${encodeURIComponent(serial)}`) +const getCharBySlot = (account, slot) => + call(`/char/${encodeURIComponent(account)}/${encodeURIComponent(slot)}`) +const getRoster = (account) => call(`/roster/${encodeURIComponent(account)}`) +const getVendors = (account) => call(`/vendors/${encodeURIComponent(account)}`) + +// History / economy series — used for WS-reconnect backfill and public feeds. +function getHistory({ kind, limit = 100 } = {}) { + const params = new URLSearchParams() + if (kind) params.set('kind', kind) + if (limit) params.set('limit', String(limit)) + const qs = params.toString() + return call(`/history${qs ? `?${qs}` : ''}`) +} +const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`) + +// ── Commands ────────────────────────────────────────────────────────────── +const confirmLink = (code, websiteUserId) => + call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } }) +const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`) +const postTownCrier = ({ id, lines, durationSec }) => + call('/towncrier', { method: 'POST', body: { id, lines, durationSec } }) +const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }) + +module.exports = { + invalidateConfig, + health, + getCharBySerial, + getCharBySlot, + getRoster, + getVendors, + getHistory, + getEconomy, + confirmLink, + linkLookup, + postTownCrier, + deleteTownCrier, +}