Compare commits
44 Commits
ci/gitea-a
...
5639117936
| Author | SHA1 | Date | |
|---|---|---|---|
| 5639117936 | |||
| 50133155d6 | |||
| bdce23f9b6 | |||
| a0a70ce2ca | |||
| 526160721a | |||
| 352ae4f256 | |||
| a16092f13a | |||
| 7a08546da6 | |||
| 1bb9e3c3c3 | |||
| b8f67fb208 | |||
| e0fadbdcc2 | |||
| b3033909d3 | |||
| 6c967d9a6c | |||
| ee085496ab | |||
| 3ef1c8e438 | |||
| 1629796235 | |||
| a165c90c62 | |||
| 2976d5982f | |||
| 91c206bf76 | |||
| 55a3adea99 | |||
| 2957708bab | |||
| e9aa19a83d | |||
| 080478c4a1 | |||
| 3b333b1b49 | |||
| 0facdb2b2a | |||
| 49ad6891cf | |||
| 97d95052db | |||
| e744723db2 | |||
| fc2554e5c3 | |||
| 70122f3626 | |||
| 64da0067f1 | |||
| 5b6b63e1bc | |||
| 01b3bb52bf | |||
| c31553aeb6 | |||
| 2dc360ca48 | |||
| 34c511c8d0 | |||
| 4fe90ea368 | |||
| ba4d758eab | |||
| 696d82f114 | |||
| f4e7fc7e20 | |||
| 6d4cd91bcc | |||
| 3628268dda | |||
| 25ff5aa836 | |||
| 042a151358 |
42
.env.example
42
.env.example
@@ -1,5 +1,14 @@
|
||||
# ─── UOMysticmoon — root environment (used by docker-compose) ───
|
||||
# ─── Runic Gateway — root environment (used by docker-compose) ───
|
||||
# Copy to .env and fill in. NEVER commit the real .env.
|
||||
# To run this as an existing branded instance (e.g. UOMysticmoon), see
|
||||
# .env.uomysticmoon.example for the exact BRAND_*/DB pinning to copy in.
|
||||
|
||||
# Container image tag pulled by docker-compose (app + bot). Published by the
|
||||
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
|
||||
# Leave as `latest` for routine deploys; pin to a specific build for a
|
||||
# reproducible deploy or rollback, e.g. IMAGE_TAG=sha-042a151.
|
||||
# Deploy: `docker compose pull && docker compose up -d`.
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# App
|
||||
NODE_ENV=production
|
||||
@@ -16,12 +25,32 @@ LOG_TO_FILE=true # set false for console-only
|
||||
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
||||
LOG_FILE=app.log
|
||||
|
||||
# ─── Branding (BRAND_*) ───────────────────────────────────────────────────
|
||||
# Instance identity. Defaults render as "Runic Gateway"; set these to rebrand
|
||||
# without a rebuild. Text + colors reach the SPA through the settings API at
|
||||
# runtime; the server templates index.html <title>/meta/OG/favicon at boot. The
|
||||
# admin-editable "site title" and "contact email" settings, if set, override
|
||||
# BRAND_NAME / BRAND_CONTACT_EMAIL.
|
||||
BRAND_NAME=Runic Gateway
|
||||
BRAND_SHORT_NAME=Runic Gateway
|
||||
BRAND_TAGLINE=an independent private Ultima Online shard
|
||||
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
||||
BRAND_CONTACT_EMAIL=
|
||||
BRAND_URL=
|
||||
# Accent color — drives the web theme's --accent and the Discord embed color.
|
||||
BRAND_ACCENT_COLOR=#7f99bd
|
||||
# Image assets: paths under the /brand mount (see docker-compose.yml) or absolute
|
||||
# URLs. Blank = built-in defaults (hero falls back to a neutral built-in image).
|
||||
BRAND_LOGO=
|
||||
BRAND_HERO=
|
||||
BRAND_FAVICON=
|
||||
|
||||
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
|
||||
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
DB_NAME=runic_gateway
|
||||
DB_USER=runic
|
||||
DB_PASSWORD=change-me-db-password
|
||||
DB_ROOT_PASSWORD=change-me-root-password
|
||||
|
||||
@@ -31,7 +60,8 @@ JWT_EXPIRES_IN=1d
|
||||
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
|
||||
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
|
||||
COOKIE_SECURE=auto
|
||||
COOKIE_NAME=uomm_token
|
||||
# Changing this on a live instance invalidates existing sessions (users re-login).
|
||||
COOKIE_NAME=rg_token
|
||||
|
||||
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
||||
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
||||
@@ -44,8 +74,8 @@ TRUST_PROXY=1
|
||||
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
||||
DEBUG_TRUST_PROXY=0
|
||||
|
||||
# Optional TOTP two-factor (opt-in per user).
|
||||
TOTP_ISSUER=UOMysticmoon
|
||||
# Optional TOTP two-factor (opt-in per user). Defaults to BRAND_NAME when unset.
|
||||
# TOTP_ISSUER=Runic Gateway
|
||||
TOTP_CHALLENGE_TTL=5m
|
||||
|
||||
# First admin bootstrap — created only if no users exist yet.
|
||||
|
||||
30
.env.uomysticmoon.example
Normal file
30
.env.uomysticmoon.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# ─── UOMysticmoon instance — BRAND_* / identity overrides ───
|
||||
#
|
||||
# Runic Gateway's first "tenant". Copy these into the deploy .env (on top of
|
||||
# .env.example) to run RunicGateway/website as UOMysticmoon. This is the proof
|
||||
# that branding is data, not code: the same image renders as UOMysticmoon purely
|
||||
# from these vars.
|
||||
#
|
||||
# Only the values that differ from the Runic Gateway defaults are shown.
|
||||
|
||||
# Identity
|
||||
BRAND_NAME=UOMysticmoon
|
||||
BRAND_SHORT_NAME=Mysticmoon
|
||||
BRAND_TAGLINE=an independent private Ultima Online shard
|
||||
BRAND_DESCRIPTION=UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
||||
BRAND_CONTACT_EMAIL=UOMysticmoon@gmail.com
|
||||
# BRAND_URL=https://<your public url>
|
||||
|
||||
# Visual — the existing UOM accent + hero image (baked into the image already).
|
||||
BRAND_ACCENT_COLOR=#7f99bd
|
||||
BRAND_HERO=/assets/img/uomysticmoon-main-hero.png
|
||||
|
||||
# TOTP label (defaults to BRAND_NAME, so optional — shown for clarity).
|
||||
TOTP_ISSUER=UOMysticmoon
|
||||
|
||||
# ── Infrastructure identifiers — PIN to the existing production values so the
|
||||
# ── app keeps talking to the same database and existing sessions stay valid.
|
||||
# ── (These are NOT branding; they must match what production already uses.)
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
COOKIE_NAME=uomm_token
|
||||
@@ -1,11 +1,21 @@
|
||||
# Build and publish the app + bot container images to Gitea's container registry
|
||||
# on every merge to main. Production then pulls prebuilt images instead of
|
||||
# building on the host.
|
||||
# Build the app + bot container images, publish them to Gitea's container
|
||||
# registry, then roll the production stack onto the fresh images — all on every
|
||||
# merge to main. Production only ever pulls prebuilt images; it never builds.
|
||||
#
|
||||
# Two jobs run in sequence:
|
||||
# build — builds & pushes website-app / website-bot images (on ubuntu-latest)
|
||||
# deploy — `needs: build`, so it starts only after a clean build+push, and
|
||||
# pulls + recreates the stack on the production host (on uom-deploy-runner)
|
||||
#
|
||||
# Prerequisites (one-time):
|
||||
# • An always-on Gitea runner with label `ubuntu-latest` whose jobs have the
|
||||
# host Docker socket mounted (/var/run/docker.sock), so `docker build` talks
|
||||
# to the host daemon. This also gives free layer caching between runs.
|
||||
# • A second self-hosted runner labelled `uom-deploy-runner` ON the production host,
|
||||
# with access to the Docker daemon and to /home/perry/website (the directory
|
||||
# holding the production docker-compose.yml + .env). This is what actually
|
||||
# rolls the stack; it must be able to `docker compose pull` from the registry
|
||||
# (log in once on the host, or ensure the images are public-read).
|
||||
# • Two repo secrets (Settings → Actions → Secrets):
|
||||
# REGISTRY_USER — the Gitea username that owns the token below
|
||||
# REGISTRY_TOKEN — a Gitea access token with `write:package` (+ read:package)
|
||||
@@ -14,6 +24,7 @@
|
||||
# Produces, in gitea.whitlocktech.com/<owner>/ :
|
||||
# website-app:latest + website-app:sha-<7>
|
||||
# website-bot:latest + website-bot:sha-<7>
|
||||
# then deploys the `:latest` images (docker-compose.yml defaults IMAGE_TAG=latest).
|
||||
|
||||
name: Build container images
|
||||
|
||||
@@ -85,3 +96,25 @@ jobs:
|
||||
- name: Log out (clear cached credentials from the runner)
|
||||
if: always()
|
||||
run: docker logout "${REGISTRY}" || true
|
||||
|
||||
deploy:
|
||||
# Roll production onto the images `build` just pushed. `needs: build` makes
|
||||
# this wait for a clean build+push — if the build fails, deploy never fires,
|
||||
# so the running stack is left untouched rather than torn down for nothing.
|
||||
needs: build
|
||||
runs-on: uom-deploy-runner
|
||||
# Guard against a workflow_dispatch fired from a non-main branch: only ever
|
||||
# deploy the main line to production.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Pull the fresh images and recreate the stack
|
||||
# `pull` grabs the new :latest images the build job published; `down`
|
||||
# then `up -d` recreates the containers on them. Compose only recreates
|
||||
# services whose image digest changed, so the DB stays put.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /home/perry/website
|
||||
docker compose pull
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
|
||||
70
.gitea/workflows/pr-checks.yml
Normal file
70
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
|
||||
# build or failing test can't reach the deployable branch. Complements
|
||||
# build-images.yml, which runs only AFTER merge (on push to main) to publish
|
||||
# images — this one runs BEFORE merge.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
# • Enable Status Check
|
||||
# • Status check patterns: PR Checks / *
|
||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||
# without needing the dropdown.
|
||||
#
|
||||
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
|
||||
# only Node (no Docker socket), and the server tests stub their models + point the
|
||||
# DB pool at a dead port, so no MariaDB service is required.
|
||||
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
- name: Install server deps
|
||||
run: npm ci --prefix server
|
||||
- name: Run server tests
|
||||
run: npm test --prefix server
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
- name: Install client deps
|
||||
run: npm ci --prefix client
|
||||
- name: Build client
|
||||
run: npm run build --prefix client
|
||||
|
||||
bot-install:
|
||||
# No tests/build to run; a clean install still catches a broken or
|
||||
# out-of-sync lockfile before it ships in the bot image.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: bot/package-lock.json
|
||||
- name: Install bot deps
|
||||
run: npm ci --prefix bot
|
||||
@@ -1,329 +0,0 @@
|
||||
# UOMysticmoon Website — Backend Design
|
||||
|
||||
> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
|
||||
> This document is the contract the later phases build against.
|
||||
|
||||
Public contact email: **UOMysticmoon@gmail.com**
|
||||
|
||||
---
|
||||
|
||||
## 1. Stack & top-level decisions
|
||||
|
||||
| Concern | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| Runtime | Node.js + Express | serverlinkr pattern |
|
||||
| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) |
|
||||
| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel |
|
||||
| Frontend | React + Vite, same repo, served by Express in prod | spec |
|
||||
| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr |
|
||||
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
|
||||
|
||||
**Adapting serverlinkr → this project**
|
||||
- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them.
|
||||
- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth".
|
||||
- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Folder structure
|
||||
|
||||
Skeleton from the spec, with a small number of justified additions marked **(+)**.
|
||||
|
||||
```
|
||||
server/
|
||||
.env.example
|
||||
package.json
|
||||
db/
|
||||
schema.sql (+) DDL, also auto-run by the MariaDB container
|
||||
seed.js (+) seed wiki pages, default settings, first admin
|
||||
src/
|
||||
server.js bootstrap: ensure schema, then listen on 0.0.0.0
|
||||
app.js express app + middleware wiring
|
||||
router/
|
||||
api.router.js mounts /v1
|
||||
v1/
|
||||
v1.router.js mounts /auth /public /admin
|
||||
auth/ auth.routes.js + auth.controller.js
|
||||
public/ public.routes.js + public.controller.js
|
||||
admin/ admin.routes.js + admin.controller.js
|
||||
model/
|
||||
users/ users.model.js + users.db.js
|
||||
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
|
||||
wiki/ wiki.model.js + wiki.db.js
|
||||
settings/ settings.model.js + settings.db.js
|
||||
activity/ activity.model.js + activity.db.js (+) admin activity log
|
||||
middleware/ (+)
|
||||
siteMode.js LIVE/MAINTENANCE gate for public content
|
||||
noindex.js X-Robots-Tag: noindex,nofollow on admin
|
||||
rateLimit.js login limiter
|
||||
validate.js express-validator error handler
|
||||
utils/
|
||||
auth.js JWT sign/verify, isLoggedIn middleware
|
||||
db.js MariaDB pool + ensureSchema()
|
||||
mailer.js (+) nodemailer; mailto fallback if SMTP unset
|
||||
client/ built in Phase 2/3 (React + Vite)
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.env.example
|
||||
.gitignore
|
||||
```
|
||||
|
||||
**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode
|
||||
gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four
|
||||
listed models/two utils. They're isolated in `middleware/` + one `activity` model +
|
||||
`utils/mailer.js`, and the spec explicitly says the layout is "expandable."
|
||||
|
||||
---
|
||||
|
||||
## 3. Database schema (MariaDB)
|
||||
|
||||
`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as
|
||||
`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`.
|
||||
|
||||
### users
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| username | VARCHAR(32) UNIQUE NOT NULL | |
|
||||
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
|
||||
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
| last_login_at | DATETIME NULL | shown in user management |
|
||||
|
||||
### posts — one table, four categories
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
|
||||
| title | VARCHAR(200) NOT NULL | |
|
||||
| slug | VARCHAR(220) NULL | optional clean URL |
|
||||
| excerpt | VARCHAR(400) NULL | list teaser |
|
||||
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
|
||||
| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere |
|
||||
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
|
||||
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
|
||||
| published_at | DATETIME NULL | set when first published; list order |
|
||||
|
||||
Index: `(category, published, published_at DESC)`.
|
||||
|
||||
### wiki_pages
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` |
|
||||
| title | VARCHAR(200) NOT NULL | |
|
||||
| body | MEDIUMTEXT NULL | markdown/HTML |
|
||||
| updated_by | INT NULL FK→users(id) | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`.
|
||||
|
||||
### settings — key/value, expandable
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| `key` | VARCHAR(64) PK | |
|
||||
| value | TEXT NULL | |
|
||||
| updated_by | INT NULL FK→users(id) | |
|
||||
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | |
|
||||
|
||||
Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
|
||||
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
|
||||
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
|
||||
|
||||
### activity_log — append-only
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| user_id | INT NULL FK→users(id) | |
|
||||
| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` |
|
||||
| detail | TEXT NULL | JSON string of what changed |
|
||||
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
## 4. API contract
|
||||
|
||||
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
||||
accepts `Authorization: Bearer` for API testing).
|
||||
|
||||
### /auth (auth.routes.js → auth.controller.js)
|
||||
| Method | Path | Auth | Body | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
|
||||
| POST | `/logout` | cookie | — | clear cookie |
|
||||
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
|
||||
|
||||
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
|
||||
admins are created under `/admin/users`.
|
||||
|
||||
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
|
||||
| GET | `/status` | status message + current mode |
|
||||
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
|
||||
| GET | `/posts/:category/:idOrSlug` | single published post |
|
||||
| GET | `/wiki` | list of pages (slug + title) |
|
||||
| GET | `/wiki/:slug` | single page |
|
||||
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
|
||||
|
||||
Public content GETs pass through the **siteMode** gate (§5).
|
||||
|
||||
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
|
||||
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
|
||||
| GET | `/posts?category=` | all posts incl. unpublished |
|
||||
| POST | `/posts` | create |
|
||||
| GET | `/posts/:id` | one |
|
||||
| PUT | `/posts/:id` | edit |
|
||||
| DELETE | `/posts/:id` | delete |
|
||||
| PATCH | `/posts/:id/publish` | `{published}` toggle (sets `published_at`) |
|
||||
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
|
||||
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
||||
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
||||
|
||||
Every admin write logs to `activity_log`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Site mode (LIVE / MAINTENANCE)
|
||||
|
||||
State in `settings.site_mode` (`live`|`maintenance`), default **maintenance**.
|
||||
|
||||
`middleware/siteMode.js`, applied only to **public content** routes:
|
||||
- `live` → pass through.
|
||||
- `maintenance` → respond **503** with `{mode:"maintenance", message}` **unless** the request
|
||||
carries a valid admin cookie (admin preview). This hides content server-side, not just in
|
||||
the UI.
|
||||
|
||||
Always reachable regardless of mode: static assets / SPA shell, `/api/v1/auth/*`, all
|
||||
`/api/v1/admin/*`. So admin login + panel + the maintenance "coming soon" page always load.
|
||||
|
||||
**Client behavior (Phase 3):** reads `GET /public/settings`; if `maintenance` and not an
|
||||
admin previewing, render the polished dark coming-soon page (message + contact email).
|
||||
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
|
||||
|
||||
Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change +
|
||||
who"; `activity_log` provides the history feed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Auth & security
|
||||
|
||||
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
||||
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
|
||||
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
|
||||
- **No directory browsing** (express.static doesn't list; no `serve-index`).
|
||||
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
|
||||
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
|
||||
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Email
|
||||
|
||||
`utils/mailer.js` (nodemailer) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
|
||||
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
|
||||
flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
|
||||
singleton (never returned over the API). The OAuth client id/secret are reused from the `google`
|
||||
auth-providers row. Recipient is the `contact_email` site setting. If email is unconfigured/disabled,
|
||||
`POST /public/contact` returns `{fallback:"mailto", email}` so the client renders a `mailto:` link
|
||||
instead. Errors never leak credentials.
|
||||
|
||||
---
|
||||
|
||||
## 7.5 Logging & observability
|
||||
|
||||
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
|
||||
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
|
||||
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
|
||||
|
||||
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
|
||||
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
|
||||
`/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL`
|
||||
(default `debug`, so the file keeps a complete record while the console stays readable).
|
||||
Toggle with `LOG_TO_FILE`. The stream is flushed on graceful shutdown.
|
||||
- **HTTP access logs** via morgan piped into the logger: real client IP (`trust proxy`),
|
||||
authenticated admin username, method, URL, status, response time, size.
|
||||
- **Captured events**: startup config banner, schema/seed steps, login success/failure,
|
||||
rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
|
||||
stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
|
||||
logged. `unhandledRejection`/`uncaughtException` are caught and logged.
|
||||
|
||||
## 8. Deployment
|
||||
|
||||
**docker-compose.yml** — two services on a private network:
|
||||
- `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume
|
||||
`dbdata:/var/lib/mysql`, mounts `schema.sql` into `/docker-entrypoint-initdb.d`, healthcheck.
|
||||
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
|
||||
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
|
||||
`ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
|
||||
- Volumes: `dbdata`, `uploads`.
|
||||
|
||||
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
|
||||
|
||||
**.env.example** (committed; real `.env` ignored):
|
||||
```
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
DB_PASSWORD=
|
||||
DB_ROOT_PASSWORD=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRES_IN=1d
|
||||
COOKIE_SECURE=true
|
||||
COOKIE_NAME=uomm_token
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dependencies (server)
|
||||
|
||||
`express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser,
|
||||
express-rate-limit, express-validator, multer, nodemailer` · dev: `nodemon`.
|
||||
Removed vs serverlinkr: `mongoose, mongodb, connect-mongo, express-session, passport,
|
||||
passport-local`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Spec coverage
|
||||
|
||||
| Spec requirement | Covered by |
|
||||
|---|---|
|
||||
| Public pages (`/`, `/site/*`, `/wiki/*`) | `/public/*` API + Phase-3 SPA routes; content from `posts`/`wiki`/`settings` |
|
||||
| News / 5-on-Friday / Newsletter / Screenshots | `posts` table, `category` column; admin CRUD + publish |
|
||||
| Wiki 8 categories, editable later | `wiki_pages` seeded with 8 slugs; admin CRUD |
|
||||
| Status page | `settings.status_message` + mode via `/public/status` |
|
||||
| Admin dashboard (mode, last change, who) | `/admin/dashboard` + settings stamps + activity log |
|
||||
| Site mode toggle | `PUT /admin/site-mode` + `siteMode` middleware |
|
||||
| Admin activity log | `activity_log` + `/admin/activity` |
|
||||
| Admin user management | `/admin/users` CRUD |
|
||||
| Site settings editing | `/admin/settings` |
|
||||
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
|
||||
| Maintenance page, admin always in, static always loads, admin preview | §5 |
|
||||
| SMTP via env, mailto fallback | §7 |
|
||||
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
|
||||
| Design tokens / hero | reused from existing `assets/css/mysticmoon.css` + hero PNG in Phase 2/3 |
|
||||
| Expandable | key/value settings, role enum, modular routers/models |
|
||||
```
|
||||
134
HERO_EDITOR.md
134
HERO_EDITOR.md
@@ -1,134 +0,0 @@
|
||||
# UOMysticmoon — Hero Canvas Editor Spec
|
||||
|
||||
> Branch: **`hero-feature`**. Build contract for the WYSIWYG portal-hero editor.
|
||||
> Derived from the design doc *Hero Canvas Editor — Design Document*, **corrected
|
||||
> to match the current codebase** and with the open questions resolved.
|
||||
> Same workflow as the wiki upgrade: design → phased build → verify.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let staff compose the portal hero (background image, overlay opacity, and floating
|
||||
elements — text, CTA buttons, moon, badge, image) in-browser, then preview and
|
||||
publish — no source edits. Layout persists as JSON in the existing `settings` table.
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Scope | **Full v1** — background/overlay, all element types, drag/resize/z-order, draft→preview→publish (built in phases) |
|
||||
| CTA buttons | **First-class `buttons` element type** (independently positioned), not baked into a text block |
|
||||
| First run | **Pre-populate** the canvas with today's hero (headline, subtitle, teaser, CTAs) as editable elements so nothing changes visually until edited |
|
||||
| Drag | **Native Pointer Events** (mouse/touch/pen), zero dependencies |
|
||||
| Font size | Stored in **px** (fixed reference canvas) |
|
||||
| Image compression | **None** server-side; client warns when a file is > ~1 MB |
|
||||
| Preview | `?preview=1` renders the **draft** by reading it through the authenticated admin settings endpoint |
|
||||
| Other pages | Out of scope for v1 (design allows a per-page key later) |
|
||||
|
||||
## 3. Corrections to the design doc (current-code reality)
|
||||
|
||||
1. **Public settings is a whitelist, not `getAll()`.** `GET /api/v1/public/settings`
|
||||
→ `settings.getPublic()` → `PUBLIC_KEYS` in
|
||||
[settings.model.js](server/src/model/settings/settings.model.js). The doc's
|
||||
"no backend changes / picked up automatically" is wrong. **Fix:** add
|
||||
`hero_layout` to `PUBLIC_KEYS` (one line). `hero_layout_draft` stays out
|
||||
(admin-only) — which is why preview reads the draft via `api.admin.getSettings()`.
|
||||
2. **Moon is a reusable component** ([MoonDot.jsx](client/src/components/MoonDot.jsx),
|
||||
props `size`/`glow`), used in logo/login/maintenance — not "only the header."
|
||||
The `moon` element reuses it; it gains an optional `color`.
|
||||
3. **Route vs. nav live in different files.** `/admin/hero` route →
|
||||
[App.jsx](client/src/App.jsx); sidebar link/title → `NAV`/`TITLES` in
|
||||
[AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx).
|
||||
4. **Admin content area is `maxWidth: 1000px`** — the editor canvas renders
|
||||
scaled-to-fit; percentage positions stay faithful.
|
||||
|
||||
Everything else in the doc matches (hardcoded `HERO_BG` + CTAs + `homepage_teaser`
|
||||
in [Portal.jsx](client/src/routes/public/Portal.jsx); `updateSettings` accepts
|
||||
arbitrary keys; `/admin/uploads` exists; default hero asset present; TEXT settings
|
||||
columns — no schema change).
|
||||
|
||||
## 4. Data model — no schema change
|
||||
|
||||
Two `settings` keys (TEXT): `hero_layout` (live) and `hero_layout_draft` (admin).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"background": { "image_url": null, "position_x": "left", "position_y": "center", "size": "cover" },
|
||||
"overlay": { "opacity": 0.72 },
|
||||
"elements": [
|
||||
{ "id": "uuid", "type": "text_block|buttons|moon|badge|image",
|
||||
"x": 50, "y": 42, "z": 1, "anchor": "center", "props": { /* per type */ } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Positions are **% of canvas** (reference width 1080, matching `.shell`), so the
|
||||
layout adapts across viewports without breakpoint data. `version` is validated
|
||||
(`=== 1`) before use; anything else falls back.
|
||||
|
||||
### Element props
|
||||
|
||||
| Type | Props |
|
||||
|---|---|
|
||||
| `text_block` | `lines: [{ text, tag(h1/h2/p/span), fontSize(px), color, weight }]`, `align` |
|
||||
| `buttons` | `items: [{ label, to, variant(primary/ghost) }]`, `align`, `gap` |
|
||||
| `moon` | `size`, `glow`, `color` |
|
||||
| `badge` | `text`, `bgColor`, `textColor`, `borderRadius` |
|
||||
| `image` | `src`, `width`(%), `alt` |
|
||||
|
||||
## 5. Backend changes
|
||||
- **One line:** add `'hero_layout'` to `PUBLIC_KEYS`. No new routes/controllers —
|
||||
layout saves through the existing `PUT /admin/settings`; images via `/admin/uploads`.
|
||||
|
||||
## 6. Frontend changes
|
||||
- **New** `client/src/components/HeroElement.jsx` — renders one element by type
|
||||
(shared by the live portal and the editor canvas).
|
||||
- **New** `client/src/routes/admin/views/HeroEditor.jsx` — canvas + element tray +
|
||||
properties panel; native-pointer drag/resize; background/overlay panel; snap grid;
|
||||
auto-save draft, preview, publish, revert.
|
||||
- **Edit** [Portal.jsx](client/src/routes/public/Portal.jsx) — parse `hero_layout`
|
||||
(or draft when `?preview=1` + admin), render elements, fall back to a
|
||||
`DEFAULT_LAYOUT` built from today's hero so the page is unchanged until edited.
|
||||
- **Edit** [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx) (nav) +
|
||||
[App.jsx](client/src/App.jsx) (route `/admin/hero`).
|
||||
- **Edit** [MoonDot.jsx](client/src/components/MoonDot.jsx) — optional `color`.
|
||||
- **No** `client/src/api/client.js` changes needed beyond what exists
|
||||
(`admin.updateSettings`, `admin.getSettings`, `admin.upload`).
|
||||
|
||||
## 7. Phased build (each phase: build → verify in preview → commit)
|
||||
|
||||
- **Phase 0 — Spec** ✅ this document.
|
||||
- **Phase 1 — Data path & renderer** ✅ (verified 2026-06-28). `hero_layout`
|
||||
whitelisted; `HeroElement.jsx`; Portal renders the layout with a `DEFAULT_LAYOUT`
|
||||
fallback. Default render matches the old hero; publishing a layout re-renders;
|
||||
draft key not exposed publicly. Shared helpers moved to `client/src/lib/heroLayout.js`.
|
||||
- **Phase 2 — Editor shell + background/overlay** ✅ (verified 2026-06-28).
|
||||
`/admin/hero` view + sidebar nav; canvas live-preview; background upload + 3×3
|
||||
position + overlay opacity; debounced draft auto-save; publish; `?preview=1`
|
||||
reads the draft (admin) with a banner; revert. Verified: overlay/position update
|
||||
the canvas, auto-save writes the draft, publish writes live, preview shows the
|
||||
draft while the normal portal shows live.
|
||||
- **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
|
||||
2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
|
||||
native Pointer Events drag (% of canvas); Delete key + panel delete; z-order
|
||||
(send back / bring forward); text_block line editor (text/tag/size/color/bold,
|
||||
add/remove lines, align) and buttons editor (label/path/variant, add/remove).
|
||||
Verified: select shows the line editor, editing a line updates the canvas live,
|
||||
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
|
||||
- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
|
||||
Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
|
||||
text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
|
||||
moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
|
||||
until a file is chosen. Verified: each type adds + edits, resize moved a moon
|
||||
64→104px, snap grid shows, and a published moon+badge render on the live portal.
|
||||
|
||||
**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
|
||||
Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
|
||||
handle for now), per-viewport layouts, server-side image compression.
|
||||
|
||||
## 8. Edge cases (from the doc, carried forward)
|
||||
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
|
||||
- Element ids via `crypto.randomUUID()` (never array index).
|
||||
- Empty `elements` → render `DEFAULT_LAYOUT` so the hero is never blank.
|
||||
- Last-write-wins on concurrent admin edits (acceptable for this shard).
|
||||
- Client-side warning for background files > ~1 MB (no hard block; 8 MB server cap).
|
||||
80
README.md
80
README.md
@@ -1,14 +1,17 @@
|
||||
# UOMysticmoon Website
|
||||
# Runic Gateway Website
|
||||
|
||||
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
|
||||
shard — a full-stack app in one repo:
|
||||
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
|
||||
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
||||
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
||||
|
||||
A full-stack app in one repo:
|
||||
|
||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([UOM/link](https://gitea.whitlocktech.com/UOM/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
||||
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
||||
|
||||
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||
The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +53,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
## Project structure
|
||||
|
||||
```
|
||||
UOMSITE/
|
||||
website/
|
||||
├─ server/ Express API
|
||||
│ ├─ src/
|
||||
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||
@@ -92,8 +95,10 @@ UOMSITE/
|
||||
|
||||
### Option A — Docker Compose (full stack)
|
||||
|
||||
The simplest way to run everything. The image installs server deps, **builds the React client**,
|
||||
and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are
|
||||
`docker-compose.yml` is **production-shaped**: it *pulls* the prebuilt `app` and `bot` images from
|
||||
the Gitea container registry (published by `.gitea/workflows/build-images.yml` on every merge to
|
||||
`main`) — it never builds. Each image already bundles the server deps and the built React client,
|
||||
which Express serves. MariaDB runs in its own container; tables + defaults + the first admin are
|
||||
created automatically on first boot.
|
||||
|
||||
```bash
|
||||
@@ -103,7 +108,9 @@ cp .env.example .env
|
||||
# JWT_SECRET (a long random string)
|
||||
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
|
||||
|
||||
docker compose up -d --build
|
||||
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
|
||||
# pin a specific build (reproducible deploy / rollback):
|
||||
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
- App: **http://localhost:3000** (binds `0.0.0.0`)
|
||||
@@ -111,6 +118,16 @@ docker compose up -d --build
|
||||
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
|
||||
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
|
||||
|
||||
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
|
||||
the dev file, which adds `build:` back:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Keeping `build:` out of the base file means a production host can only ever pull — it can never
|
||||
accidentally build.
|
||||
|
||||
### Option B — Local development (hot reload)
|
||||
|
||||
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
|
||||
@@ -119,14 +136,14 @@ to the backend, so the SPA stays same-origin (cookies work).
|
||||
**1. Start a MariaDB the backend can reach** (published on `localhost:3306`):
|
||||
|
||||
```bash
|
||||
docker run -d --name uomm-db -p 3306:3306 -e MARIADB_DATABASE=uomysticmoon -e MARIADB_USER=uomm -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
|
||||
docker run -d --name rg-db -p 3306:3306 -e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
|
||||
```
|
||||
|
||||
**2. Configure + start the backend** (terminal 1):
|
||||
|
||||
```bash
|
||||
cp server/.env.example server/.env
|
||||
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass,
|
||||
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=runic, DB_PASSWORD=devpass,
|
||||
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
|
||||
npm run install-server
|
||||
npm run server # nodemon → http://localhost:3000
|
||||
@@ -215,7 +232,7 @@ npm start # node server → serves API + SPA at http://localhost:3
|
||||
|
||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
||||
See [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
||||
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
||||
|
||||
---
|
||||
@@ -235,7 +252,7 @@ actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `4
|
||||
|
||||
**Authentication in the UI** — click **Authorize** and provide either:
|
||||
|
||||
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
|
||||
- `cookieAuth` — the session cookie (name `rg_token`, configurable via `COOKIE_NAME`; set automatically in the browser after
|
||||
`POST /api/v1/auth/login`), or
|
||||
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
||||
`Authorization: Bearer <token>`).
|
||||
@@ -260,7 +277,7 @@ not crash).
|
||||
|
||||
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
|
||||
runs next to the ServUO shard. Its source lives in a separate repo:
|
||||
**[UOM/link](https://gitea.whitlocktech.com/UOM/link)**. uo-link speaks the shard's internals and
|
||||
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
|
||||
exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
|
||||
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
|
||||
to it.
|
||||
@@ -268,7 +285,7 @@ to it.
|
||||
### How it works
|
||||
|
||||
```
|
||||
ServUO shard ──▶ uo-link sidecar (UOM/link) ──▶ website backend ──▶ browser
|
||||
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
|
||||
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
||||
```
|
||||
|
||||
@@ -334,19 +351,20 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
|
||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
||||
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
||||
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
||||
| `COOKIE_NAME` | `uomm_token` | |
|
||||
| `COOKIE_NAME` | `rg_token` | changing it on a live instance invalidates existing sessions |
|
||||
| `BRAND_*` | Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see [Branding](#branding) |
|
||||
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
|
||||
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
|
||||
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
|
||||
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
|
||||
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
|
||||
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
|
||||
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
|
||||
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||
@@ -358,6 +376,32 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
|
||||
---
|
||||
|
||||
## Branding
|
||||
|
||||
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
|
||||
image can run as any shard. With none set, everything renders as **Runic Gateway**.
|
||||
|
||||
| Var | What |
|
||||
|---|---|
|
||||
| `BRAND_NAME` / `BRAND_SHORT_NAME` | display name (full / short-in-prose) |
|
||||
| `BRAND_TAGLINE` / `BRAND_DESCRIPTION` | tagline + meta/OG description |
|
||||
| `BRAND_CONTACT_EMAIL` / `BRAND_URL` | contact + canonical URL (for OG/absolute links) |
|
||||
| `BRAND_ACCENT_COLOR` | theme `--accent` (web) + Discord embed color |
|
||||
| `BRAND_LOGO` / `BRAND_HERO` / `BRAND_FAVICON` | image paths under the `/brand` mount, or absolute URLs |
|
||||
|
||||
**How it flows:** text/colors reach the SPA at runtime through the public settings
|
||||
API (`SiteContext`), so no rebuild is needed; the server templates `index.html`
|
||||
`<title>`/meta/OG/favicon at boot; emails, TOTP issuer, and the Discord bot read
|
||||
`BRAND_*` directly. The admin-editable **site title** and **contact email**
|
||||
settings override `BRAND_NAME` / `BRAND_CONTACT_EMAIL` when set. Image assets are
|
||||
delivered from the `./brand` bind-mount (see `brand/README.md`).
|
||||
|
||||
**UOMysticmoon** is the first instance — [`.env.uomysticmoon.example`](.env.uomysticmoon.example)
|
||||
holds the exact `BRAND_*` + infra (`DB_NAME`/`DB_USER`/`COOKIE_NAME`) pinning to
|
||||
run this repo as UOMysticmoon.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
**Session & authorization**
|
||||
|
||||
366
WIKI_UPGRADE.md
366
WIKI_UPGRADE.md
@@ -1,366 +0,0 @@
|
||||
# UOMysticmoon Website — Wiki Upgrade Spec
|
||||
|
||||
> Branch: **`wiki-upgrade`**. This document is the contract for upgrading the CMS
|
||||
> wiki from a flat single-table page store into a feature-complete wiki.
|
||||
> It follows the project workflow: **design (this doc) → build in phases → verify**.
|
||||
>
|
||||
> Companion to [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md); reuses its stack, auth,
|
||||
> logging, and Docker decisions unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & scope
|
||||
|
||||
Turn the wiki into something that behaves like a typical wiki, while staying inside
|
||||
the existing Node/Express + MariaDB + React/Vite architecture and the **staff-only**
|
||||
auth model (admin/editor — no new roles, no public contributions).
|
||||
|
||||
**In scope**
|
||||
|
||||
| Feature | Summary |
|
||||
|---|---|
|
||||
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
|
||||
| Sanitization | Server-side allowlist on save **and** client-side on render (fixes today's stored-XSS gap) |
|
||||
| Categories / sections | First-class `wiki_categories` table; replaces hardcoded frontend blurbs |
|
||||
| Drafts & publish | `published` + `published_at`, mirroring the `posts` pattern |
|
||||
| Tags | Many-to-many tags with filtering |
|
||||
| Internal links | `[[slug]]`-style links authored in the editor; red-link detection |
|
||||
| Backlinks | "Linked from" list, maintained on save |
|
||||
| Inline images | Reuse/generalize the existing multer upload for in-body images |
|
||||
| Search | MariaDB `FULLTEXT` over title + body |
|
||||
| Revision history | Per-save snapshots with view / diff / restore |
|
||||
|
||||
**Out of scope (this branch)**
|
||||
|
||||
- Public/player editing or suggestion workflow, moderation/review queues.
|
||||
- New roles or per-page ACLs (all staff with a login can edit all pages).
|
||||
- Real-time collaborative editing, comments/discussion pages, file attachments
|
||||
other than images, page templates/transclusion, multilingual pages.
|
||||
|
||||
**Decisions locked from planning**
|
||||
|
||||
- Editor: **TipTap**, storing **HTML** (not Markdown, not JSON).
|
||||
- Search: **MariaDB FULLTEXT** (no new infrastructure).
|
||||
- Revision history and search are **included** (recommended additions beyond the
|
||||
minimum requested set).
|
||||
- Authoring is **admin + editor** (`isLoggedIn`); no anonymous edits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (baseline being replaced)
|
||||
|
||||
| Layer | Today | File |
|
||||
|---|---|---|
|
||||
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
|
||||
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
|
||||
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
|
||||
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
|
||||
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
|
||||
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
|
||||
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
|
||||
|
||||
**Known issues this upgrade resolves**
|
||||
|
||||
- **Stored XSS**: body is raw HTML rendered with `dangerouslySetInnerHTML` and never
|
||||
sanitized ([WikiArticle.jsx:91](client/src/routes/wiki/WikiArticle.jsx)).
|
||||
- Category blurbs and ordering are **faked in the component** ([Wiki.jsx:11](client/src/routes/wiki/Wiki.jsx)), not data.
|
||||
- No drafts (every save is instantly public), no history, no search, no tags, no links.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
`utf8mb4`, InnoDB throughout. All changes are **additive and idempotent** so
|
||||
`ensureSchema()` upgrades existing databases on boot with no data loss. New columns
|
||||
are nullable or have safe defaults; **existing pages default to `published = 1`** so
|
||||
nothing disappears on deploy.
|
||||
|
||||
### 3.1 `wiki_categories` (new)
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AI | |
|
||||
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `guides` |
|
||||
| title | VARCHAR(200) NOT NULL | |
|
||||
| description | VARCHAR(400) NULL | card teaser on the wiki index |
|
||||
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
|
||||
| created_at / updated_at | DATETIME | standard stamps |
|
||||
|
||||
### 3.2 `wiki_pages` (altered)
|
||||
|
||||
Add to the existing table:
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
|
||||
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
|
||||
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
|
||||
| published_at | DATETIME NULL | set on first publish |
|
||||
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
|
||||
| FULLTEXT idx_wiki_search (title, body) | | search |
|
||||
|
||||
### 3.3 `wiki_tags` + `wiki_page_tags` (new)
|
||||
|
||||
```
|
||||
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
|
||||
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
|
||||
tag_id FK→wiki_tags ON DELETE CASCADE,
|
||||
PRIMARY KEY(page_id, tag_id) )
|
||||
```
|
||||
|
||||
### 3.4 `wiki_links` (new) — backlinks index
|
||||
|
||||
Rebuilt for a page on every save by parsing its body for internal links.
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
|
||||
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
|
||||
| INDEX idx_wiki_links_target (target_slug) | | backlink lookups |
|
||||
|
||||
Backlinks for page X = `SELECT source pages WHERE target_slug = X.slug AND source is published`.
|
||||
|
||||
### 3.5 `wiki_revisions` (new) — history
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AI | |
|
||||
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
|
||||
| title / body / excerpt | snapshot of content at save time | |
|
||||
| category_id | INT NULL | snapshot |
|
||||
| editor_id | INT NULL FK→users(id) | who saved |
|
||||
| change_note | VARCHAR(280) NULL | optional summary |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
|
||||
A revision is written **inside the same transaction** as each page create/update.
|
||||
|
||||
### 3.6 Seed changes
|
||||
|
||||
Rework [seed.js](server/db/seed.js): the current 8 hardcoded pages become **categories**
|
||||
(title + the blurb currently living in the frontend), each seeded idempotently via a new
|
||||
`seedDefaultCategory`. Existing seeded pages are migrated/attached where applicable.
|
||||
`seedDefault` for pages stays `INSERT IGNORE` so reseeding is safe.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend changes
|
||||
|
||||
Keep the `model` (entity) / `db` (SQL) split and the route grouping by access level.
|
||||
|
||||
### 4.1 Models (`server/src/model/wiki/`)
|
||||
|
||||
- `wiki.db.js` — add SQL for: category CRUD; page list with `category`, `published`,
|
||||
`q` (FULLTEXT) filters and ordering; tag upsert + attach/detach; `wiki_links` rebuild;
|
||||
revision insert/list/get; backlink query.
|
||||
- `wiki.model.js` — orchestration. On **create/update** (single transaction):
|
||||
1. sanitize `body` with the allowlist (§6),
|
||||
2. upsert the page,
|
||||
3. insert a `wiki_revisions` snapshot,
|
||||
4. parse body for internal links → rebuild `wiki_links` for the page,
|
||||
5. sync tags.
|
||||
- A small `wiki.links.js` helper: parse internal links out of the saved HTML
|
||||
(anchors written by the editor as `href="/wiki/<slug>"` / a `data-wiki-slug` attr),
|
||||
return the set of target slugs.
|
||||
|
||||
### 4.2 Public API (`/api/v1/public`)
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/wiki/categories` | ordered categories with page counts |
|
||||
| GET | `/wiki?category=&tag=&q=` | **published only**; list/filter/search summaries |
|
||||
| GET | `/wiki/:slug` | page + category + tags + backlinks (published only) |
|
||||
|
||||
Still passes through the `siteMode` maintenance gate like other public content.
|
||||
|
||||
### 4.3 Admin API (`/api/v1/admin`, behind `isLoggedIn` + `noindex`)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/wiki` | all pages incl. drafts (filters: category, tag, q, status) |
|
||||
| GET | `/wiki/:slug` | one page incl. draft, tags, category |
|
||||
| POST | `/wiki` | create (slug, title, body, excerpt, category_id, tags, published) |
|
||||
| PUT | `/wiki/:slug` | update (allows slug rename — see §7) |
|
||||
| PATCH | `/wiki/:slug/publish` | `{published}` toggle, stamps `published_at` |
|
||||
| DELETE | `/wiki/:slug` | delete (cascades revisions/links/tags) |
|
||||
| GET | `/wiki/:slug/revisions` | list snapshots |
|
||||
| GET | `/wiki/:slug/revisions/:id` | one snapshot (for diff/preview) |
|
||||
| POST | `/wiki/:slug/revisions/:id/restore` | restore (writes a new revision) |
|
||||
| GET/POST/PUT/DELETE | `/wiki/categories[...]` | category CRUD + reorder |
|
||||
| GET/POST | `/wiki/tags` | list/create tags |
|
||||
| POST | `/uploads` | generalized image upload (see §4.4) → `{url}` |
|
||||
|
||||
Validation via `express-validator` (slug regex `^[a-z0-9-]+$`, title required, etc.),
|
||||
centralized error handler unchanged. **Every write logs to `activity_log`**
|
||||
(`wiki.create`, `wiki.update`, `wiki.publish`, `wiki.delete`, `wiki.revision.restore`,
|
||||
`wiki.category.*`) following the existing convention.
|
||||
|
||||
### 4.4 Image uploads
|
||||
|
||||
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
|
||||
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
|
||||
the post editor and the wiki editor. Same size/mime limits. No new storage —
|
||||
served from the existing `uploads/` volume.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend changes
|
||||
|
||||
### 5.1 Admin
|
||||
|
||||
- **`WikiEditor.jsx`** — replace the raw-HTML `<textarea>` with a **TipTap** editor:
|
||||
bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, **image insert**
|
||||
(uploads via `/admin/uploads`), and an **internal-link picker** (`[[`-triggered
|
||||
autocomplete over existing slugs; flags red links). Adds: category dropdown, tag
|
||||
input (create-on-type), excerpt field, **Save draft / Publish** actions, and a
|
||||
**History** tab (revision list → preview → diff → restore).
|
||||
- **`WikiAdmin.jsx`** — list gains status (draft/published), category column, and
|
||||
filters; plus a **Categories** manager (CRUD + drag-to-reorder).
|
||||
|
||||
### 5.2 Public
|
||||
|
||||
- **`Wiki.jsx`** — fully data-driven: categories + real excerpts from the API
|
||||
(delete the hardcoded `BLURBS`/`ROMAN` constants), a **search box**, optional
|
||||
tag filter.
|
||||
- **`WikiArticle.jsx`** — keep auto-TOC; add category breadcrumb, tag chips, a
|
||||
**"Linked from"** backlinks section, "last updated by", and **render via DOMPurify**
|
||||
(`dangerouslySetInnerHTML` only after sanitize).
|
||||
|
||||
### 5.3 API client & routes
|
||||
|
||||
- Extend [client/src/api/client.js](client/src/api/client.js) with the new public/admin
|
||||
wiki calls (categories, search params, revisions, tags, uploads).
|
||||
- Add a public search/category route if needed; admin categories view registered in
|
||||
[App.jsx](client/src/App.jsx) under `/admin/wiki` (sub-tab, no new top-level route required).
|
||||
|
||||
### 5.4 Dependencies (new)
|
||||
|
||||
- **client**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`,
|
||||
`@tiptap/extension-image` (+ a small diff lib for history, e.g. `diff`); `dompurify`.
|
||||
- **server**: `sanitize-html`.
|
||||
|
||||
(The client currently ships only React + react-router, so this is the first feature
|
||||
dependency addition — keep the bundle lean, import only the extensions used.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Security
|
||||
|
||||
- **Two-layer sanitization.** Server sanitizes on save with a strict `sanitize-html`
|
||||
allowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt],
|
||||
strong/em, hr, table basics); strips scripts, event handlers, `javascript:` URLs,
|
||||
styles. Client re-sanitizes with DOMPurify before render. The stored value is already
|
||||
clean, so even direct DB edits or future API clients can't inject script.
|
||||
- **Upload safety** unchanged from posts: mime allowlist (png/jpe/gif/webp/avif),
|
||||
8 MB cap, random filenames, served as static files (no execution).
|
||||
- **Authorization**: all mutating wiki/category/tag/upload routes stay behind
|
||||
`isLoggedIn` (admin or editor). Public routes are read-only and published-only.
|
||||
- **No secrets/logging changes**; reuse existing rate-limit, helmet/CSP, noindex.
|
||||
CSP `img-src` already covers `/uploads`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration & backward compatibility
|
||||
|
||||
- Schema migration is additive; run by `ensureSchema()` on boot and shipped in
|
||||
`schema.sql` for fresh containers. Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
|
||||
/ `ADD INDEX` guarded for idempotency (MariaDB 11 supports `IF NOT EXISTS`).
|
||||
- Existing pages: `published` backfills to `1`, `published_at` to `updated_at`,
|
||||
`category_id` left NULL (surface as "Uncategorized" until assigned).
|
||||
- **Slug rename** (new capability): on `PUT` slug change, update the page slug and
|
||||
best-effort rewrite known internal links pointing at the old slug; old slug is not
|
||||
auto-redirected (acceptable for a staff-curated wiki) — note in release notes.
|
||||
- Public API response shape is **extended, not broken**: existing fields
|
||||
(`slug`, `title`, `body`, `updated_at`) remain; new fields are additive, so the
|
||||
current frontend keeps working between phases.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation process (phased)
|
||||
|
||||
Each phase is a self-contained, shippable unit: build → run locally → verify in the
|
||||
browser preview → commit on `wiki-upgrade`. Open a PR into `main` at the end (or per
|
||||
phase if preferred). Do not merge a phase that hasn't been verified.
|
||||
|
||||
### Phase 0 — Branch & scaffolding ✅ (this doc)
|
||||
- `wiki-upgrade` branch created; this spec committed.
|
||||
|
||||
### Phase 1 — Foundation & safety (highest value) ✅
|
||||
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
|
||||
published_at, sort_order, FULLTEXT), update `seed.js`.
|
||||
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
|
||||
public list filtered to published + categories endpoint.
|
||||
- Client: data-driven `Wiki.jsx` (remove hardcoded blurbs); DOMPurify render in
|
||||
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
|
||||
- **Exit check**: existing pages still render; XSS payload in body is neutralized;
|
||||
draft pages hidden from the public list/article.
|
||||
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
|
||||
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
|
||||
the public API and are absent from the public list while visible in admin; public
|
||||
index is data-driven (categories + sections); article shows category breadcrumb;
|
||||
client builds and server boots with no errors.
|
||||
|
||||
### Phase 2 — Authoring UX ✅
|
||||
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.
|
||||
- **Exit check**: create/edit a page with headings, a list, a link, and an inline
|
||||
image; verify it renders sanitized on the public page.
|
||||
- **Verified** (2026-06-27): `/admin/uploads` returns `{url}` and the file serves as
|
||||
an image; a page authored with H2/H3, lists, a link, and an uploaded inline image
|
||||
round-trips through the WYSIWYG and renders sanitized publicly (link `rel` forced,
|
||||
`<script>` stripped); a toolbar edit (insert divider) saved and persisted. TipTap
|
||||
is code-split into its own chunk (lazy-loaded), keeping it off the public bundle.
|
||||
|
||||
### Phase 3 — Connectivity ✅
|
||||
- Internal `[[slug]]` links + red-link detection; `wiki_links` rebuild on save;
|
||||
backlinks on the article; tags + tag/category filtering.
|
||||
- **Exit check**: link page A→B, confirm B shows A under "Linked from"; tag filter works.
|
||||
- **Verified** (2026-06-27): internal links authored via an in-editor page picker
|
||||
(links to `/wiki/<slug>`); A→B made B list A under "Linked from"; a link to a
|
||||
non-existent page renders as a red link; removing the link on save cleared the
|
||||
backlink (link index rebuilt). Tags upsert on save, filter via `?tag=` (chips +
|
||||
flat index view), list with published counts, and orphan tags are auto-pruned.
|
||||
- Implementation note: links are plain anchors to `/wiki/<slug>` (the WYSIWYG fits
|
||||
this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`.
|
||||
|
||||
### Phase 4 — Discovery & trust ✅
|
||||
- FULLTEXT search (public search box + admin filter); revision history list /
|
||||
diff / restore.
|
||||
- **Exit check**: search returns expected pages; edit a page twice, diff the
|
||||
revisions, restore an older one, confirm a new revision is recorded.
|
||||
- **Verified** (2026-06-27): `?q=` natural-language search matches on both body
|
||||
(`recipes`→crafting) and title (`monsters`); the public search box and admin
|
||||
filter both work. A page edited twice produced 3 revisions; the History modal
|
||||
shows a word-level diff (added vs removed) of an old revision against current;
|
||||
restoring reverted the page and appended a "Restored from revision #N" entry.
|
||||
|
||||
### Verification (every phase)
|
||||
Use the preview workflow, not manual hand-off: start the dev server, exercise the
|
||||
public wiki and the admin editor, check console/network for errors, and capture a
|
||||
screenshot of the changed surface. Confirm `npm run` lint/build passes for the client
|
||||
and the server boots cleanly with `ensureSchema()` applying the migration.
|
||||
|
||||
---
|
||||
|
||||
## 9. File-change map (reference)
|
||||
|
||||
| Area | Files |
|
||||
|---|---|
|
||||
| Schema/seed | `server/db/schema.sql`, `server/db/seed.js`, `server/src/utils/db.js` (ensureSchema) |
|
||||
| Models | `server/src/model/wiki/wiki.db.js`, `wiki.model.js`, **new** `wiki.links.js` |
|
||||
| API | `server/src/router/v1/public/public.{routes,controller}.js`, `server/src/router/v1/admin/admin.{routes,controller}.js` |
|
||||
| Sanitize | **new** `server/src/utils/sanitizeHtml.js` |
|
||||
| Client API | `client/src/api/client.js` |
|
||||
| Public UI | `client/src/routes/wiki/Wiki.jsx`, `WikiArticle.jsx` |
|
||||
| Admin UI | `client/src/routes/admin/views/WikiAdmin.jsx`, `WikiEditor.jsx`, **new** category manager + revisions view |
|
||||
| Deps | `client/package.json`, `server/package.json` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions / assumptions
|
||||
|
||||
1. **Slug redirects**: assumed not needed on rename (staff wiki). Revisit if pages get
|
||||
external inbound links.
|
||||
2. **Search ranking**: FULLTEXT natural-language mode assumed; can switch to BOOLEAN
|
||||
mode if operators are wanted later.
|
||||
3. **Diff granularity**: line/word diff of the HTML source is assumed sufficient for
|
||||
revision compare; a rendered visual diff is a later nice-to-have.
|
||||
4. **Editor scope**: tables and embeds beyond images are deferred unless requested.
|
||||
@@ -1,4 +1,4 @@
|
||||
# ─── UOMysticmoon Discord bot — local dev environment ───
|
||||
# ─── Runic Gateway Discord bot — local dev environment ───
|
||||
# Copy to bot/.env for running `npm run dev` outside Docker.
|
||||
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||
#
|
||||
@@ -40,6 +40,6 @@ SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
|
||||
# etc.) directly. Point this at the same DB the server/ uses.
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
DB_NAME=runic_gateway
|
||||
DB_USER=runic
|
||||
DB_PASSWORD=change-me-db-password
|
||||
|
||||
4
bot/package-lock.json
generated
4
bot/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "uomysticmoon-bot",
|
||||
"name": "runic-gateway-bot",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "uomysticmoon-bot",
|
||||
"name": "runic-gateway-bot",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "uomysticmoon-bot",
|
||||
"name": "runic-gateway-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord bot for the UOMysticmoon community server",
|
||||
"description": "Discord bot for the Runic Gateway community server",
|
||||
"private": true,
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
|
||||
62
bot/src/bootstrap.js
vendored
62
bot/src/bootstrap.js
vendored
@@ -4,11 +4,50 @@
|
||||
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
||||
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
||||
// extra HTTP client dependency needed for a single startup call.
|
||||
//
|
||||
// The fetch RETRIES with backoff: on `docker compose up`, the bot and the app
|
||||
// start together and the bot's `depends_on: app` only waits for the container
|
||||
// to *start*, not for the app's internal server to be listening (it still has
|
||||
// to reach the DB and boot Express). Without retries the very first fetch loses
|
||||
// that race, bootstrap gives up, and the bot sits disconnected while the DB
|
||||
// still says enabled — the exact "enabled but disconnected until I toggle it"
|
||||
// bug. Retrying until the site answers makes a cold whole-stack start heal on
|
||||
// its own.
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
const log = createLogger('bootstrap')
|
||||
|
||||
const MAX_ATTEMPTS = 30 // ~30 tries * ~2s ≈ 1 min of patience for the app to come up
|
||||
const RETRY_DELAY_MS = 2000
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Fetch config from the site, retrying while the site is unreachable or not yet
|
||||
// ready (network error or 5xx). Returns the parsed config, or null if we gave
|
||||
// up after MAX_ATTEMPTS. A 4xx (e.g. bad internal key) is a real misconfig, not
|
||||
// a transient startup race, so we don't retry those.
|
||||
async function fetchConfig(siteUrl, key) {
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||
if (res.ok) return await res.json()
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
log.error('boot-time config fetch rejected — not retrying', { status: res.status })
|
||||
return null
|
||||
}
|
||||
log.warn('boot-time config fetch not ready — retrying', { status: res.status, attempt })
|
||||
} catch (err) {
|
||||
log.warn('boot-time config fetch errored — retrying', { message: err.message, attempt })
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS)
|
||||
}
|
||||
log.error('boot-time config fetch gave up after retries — staying disconnected until the admin panel pushes config', {
|
||||
attempts: MAX_ATTEMPTS,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const siteUrl = process.env.SITE_INTERNAL_URL
|
||||
const key = process.env.BOT_INTERNAL_KEY
|
||||
@@ -17,21 +56,18 @@ async function bootstrap() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||
if (!res.ok) {
|
||||
log.error('boot-time config fetch failed', { status: res.status })
|
||||
return
|
||||
}
|
||||
const config = await res.json()
|
||||
if (config.enabled) {
|
||||
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||
const config = await fetchConfig(siteUrl, key)
|
||||
if (!config) return
|
||||
|
||||
if (config.enabled) {
|
||||
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||
try {
|
||||
await discordManager.start({ token: config.token, guildId: config.guildId })
|
||||
} else {
|
||||
log.info('boot-time config says disabled — staying disconnected')
|
||||
} catch (err) {
|
||||
log.error('boot-time reconnect failed', { message: err.message })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('boot-time config fetch errored', { message: err.message })
|
||||
} else {
|
||||
log.info('boot-time config says disabled — staying disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
bot/src/brand.js
Normal file
13
bot/src/brand.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
|
||||
// logs carry the instance identity. Kept minimal — the bot only needs the name
|
||||
// and the accent color (as an int for discord.js embeds).
|
||||
require('dotenv').config()
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
const accentInt = (() => {
|
||||
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
})()
|
||||
|
||||
module.exports = { name, accentHex, accentInt }
|
||||
@@ -12,7 +12,7 @@ const pool = mariadb.createPool({
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'uomysticmoon',
|
||||
database: process.env.DB_NAME || 'runic_gateway',
|
||||
connectionLimit: 5,
|
||||
insertIdAsNumber: true,
|
||||
bigIntAsNumber: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
} = require('discord.js')
|
||||
|
||||
const roleMenus = require('../../model/roleMenus')
|
||||
const brand = require('../../brand')
|
||||
|
||||
// Capped at 5 roles per menu — a single Discord action row holds at most 5
|
||||
// buttons, and one row keeps this a single simple slash command instead of
|
||||
@@ -62,7 +63,7 @@ module.exports = {
|
||||
return
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
|
||||
const embed = new EmbedBuilder().setTitle(title).setColor(brand.accentInt)
|
||||
if (description) embed.setDescription(description)
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const brand = require('../brand')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('news')
|
||||
@@ -15,7 +16,7 @@ async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl })
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
|
||||
|
||||
const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url)
|
||||
const embed = new EmbedBuilder().setColor(brand.accentInt).setTitle(title).setURL(url)
|
||||
if (excerpt) embed.setDescription(excerpt)
|
||||
if (imageUrl) embed.setImage(imageUrl)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
|
||||
// Gate for the bot's /internal/* API. The only caller is the main Runic Gateway
|
||||
// server, over the private compose network — never expose this route through
|
||||
// the public reverse proxy. Timing-safe compare so response time can't be used
|
||||
// to brute-force the shared secret one byte at a time.
|
||||
|
||||
@@ -4,6 +4,7 @@ const app = require('./app')
|
||||
const bootstrap = require('./bootstrap')
|
||||
const createLogger = require('./utils/logger')
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const brand = require('./brand')
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const log = createLogger('server')
|
||||
@@ -11,7 +12,7 @@ const PORT = Number(process.env.PORT) || 4100
|
||||
const HOST = '0.0.0.0'
|
||||
|
||||
async function start() {
|
||||
log.info(`starting UOMysticmoon bot v${pkg.version}`, {
|
||||
log.info(`starting ${brand.name} bot v${pkg.version}`, {
|
||||
node: process.version,
|
||||
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||
})
|
||||
|
||||
15
brand/README.md
Normal file
15
brand/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Brand assets (per-instance)
|
||||
|
||||
This directory is bind-mounted into the container at `/app/brand` (see
|
||||
`docker-compose.yml`). Drop instance branding images here and point the matching
|
||||
`BRAND_*` env vars at them, e.g.:
|
||||
|
||||
```
|
||||
BRAND_LOGO=/brand/logo.png
|
||||
BRAND_HERO=/brand/hero.png
|
||||
BRAND_FAVICON=/brand/favicon.ico
|
||||
```
|
||||
|
||||
Leave the vars blank to use the built-in defaults (the hero falls back to a
|
||||
neutral built-in image; no logo/favicon is injected). Nothing here is required
|
||||
for the app to run — it renders cleanly with an empty `brand/`.
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UOMysticmoon</title>
|
||||
<meta name="description" content="UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<title>Runic Gateway</title>
|
||||
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "uomysticmoon-client",
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "uomysticmoon-client",
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "uomysticmoon-client",
|
||||
"name": "runic-gateway-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
|
||||
BIN
client/public/assets/img/favicon.ico
Normal file
BIN
client/public/assets/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
client/public/assets/img/runic-emblem.png
Normal file
BIN
client/public/assets/img/runic-emblem.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -18,6 +18,10 @@ import About from './routes/public/About.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||
import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -36,10 +40,14 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
||||
import ShardOps from './routes/admin/views/ShardOps.jsx'
|
||||
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
@@ -47,6 +55,7 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
// Player portal
|
||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
@@ -57,7 +66,12 @@ export default function App() {
|
||||
<AuthProvider>
|
||||
<SiteProvider>
|
||||
<Routes>
|
||||
{/* Public site — gated by maintenance mode (admins preview through it) */}
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
||||
<Route path="/" element={<Portal />} />
|
||||
|
||||
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||
<Route
|
||||
element={
|
||||
<MaintenanceGate>
|
||||
@@ -65,7 +79,6 @@ export default function App() {
|
||||
</MaintenanceGate>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Portal />} />
|
||||
<Route path="/site" element={<Website />} />
|
||||
<Route path="/site/news" element={<News />} />
|
||||
<Route path="/site/screenshots" element={<Screenshots />} />
|
||||
@@ -76,6 +89,10 @@ export default function App() {
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
@@ -120,10 +137,28 @@ export default function App() {
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route
|
||||
path="shard-ops"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="users/:id" element={<UserDetail />} />
|
||||
<Route path="invites" element={<InvitesAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
@@ -131,6 +166,7 @@ export default function App() {
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
|
||||
@@ -48,6 +48,10 @@ export const api = {
|
||||
// optional email. Returns { user } and sets the session cookie on success.
|
||||
register: (username, password, extra = {}) =>
|
||||
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
||||
// Email invites (public, token-gated accept).
|
||||
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||
acceptInvite: (token, username, password, extra = {}) =>
|
||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||
loginTotp: (challenge, code) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||
@@ -94,6 +98,14 @@ export const api = {
|
||||
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) =>
|
||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
|
||||
presence: () => req('/public/shard/presence'),
|
||||
houses: () => req('/public/shard/houses'),
|
||||
},
|
||||
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
||||
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||
@@ -159,9 +171,30 @@ export const api = {
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
listUsers: () => req('/admin/users'),
|
||||
getUser: (id) => req(`/admin/users/${id}`),
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
// Email invites.
|
||||
listInvites: () => req('/admin/invites'),
|
||||
createInvite: (email, role, sendEmail = true) =>
|
||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
||||
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
||||
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
||||
// account) so the shared GameAccounts component works unchanged.
|
||||
userShard: (id) => ({
|
||||
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||
online: () => req(`/admin/users/${id}/shard/online`),
|
||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
||||
}),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
@@ -227,6 +260,9 @@ export const api = {
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/admin/shard/sales'),
|
||||
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
||||
createAccount: (account, password) =>
|
||||
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
@@ -245,6 +281,20 @@ export const api = {
|
||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
shardOps: {
|
||||
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
||||
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
||||
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
||||
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
||||
pages: () => req('/admin/shard/pages'),
|
||||
respondPage: (id, data) =>
|
||||
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
||||
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
||||
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
|
||||
},
|
||||
|
||||
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||
getEmailConfig: () => req('/admin/email/config'),
|
||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||
@@ -276,6 +326,9 @@ export const api = {
|
||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/player/shard/sales'),
|
||||
houses: () => req('/player/shard/houses'), // the caller's own houses
|
||||
createAccount: (account, password) =>
|
||||
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
// Reusable character-sheet renderer for the char.profile shape returned by
|
||||
// /public/shard/char/:serial. Presentational only — the parent handles loading
|
||||
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
|
||||
//
|
||||
// `moderation` opts in the in-game kick/ban controls for the character's account;
|
||||
// they self-gate to staff (ShardAccountActions), so passing it from a page a
|
||||
// player can reach is safe.
|
||||
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||
// literal string. Without a cliloc table on the site we can only show literals, so
|
||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||
// de-duped list of human-readable title chips.
|
||||
function displayTitles(titles) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||
// Prefer the selected reward title; fall back to the first literal one.
|
||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||
@@ -28,7 +66,7 @@ function Vital({ label, cur, max }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function CharacterSheet({ char }) {
|
||||
export default function CharacterSheet({ char, moderation = false }) {
|
||||
if (!char) return null
|
||||
const stats = char.stats || {}
|
||||
const resist = stats.resist || {}
|
||||
@@ -58,6 +96,29 @@ export default function CharacterSheet({ char }) {
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||
</div>
|
||||
|
||||
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||
{char.governorOf && char.governorOf.map((city) => (
|
||||
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||
))}
|
||||
{char.guild && (
|
||||
<TitleChip tone="var(--accent)">
|
||||
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||
</TitleChip>
|
||||
)}
|
||||
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||
{moderation && char.acct && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
|
||||
<ShardAccountActions account={char.acct} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Core stats */}
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||
|
||||
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// Reusable "create a game account" form (its own username + password — the game
|
||||
// client credentials, distinct from the website login). Calls `submit(account,
|
||||
// password)` which should POST /player/shard/account; on success calls onCreated.
|
||||
// Used by the player portal (self-serve) and the invite-accept page alike.
|
||||
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
|
||||
const [account, setAccount] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
|
||||
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
|
||||
}
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await submit(account, password)
|
||||
setMsg(`Game account “${account}” created and linked.`)
|
||||
setAccount(''); setPassword('')
|
||||
if (onCreated) await onCreated()
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That account name is already taken.')
|
||||
else if (err.status === 429) setError('The account limit for your network has been reached.')
|
||||
else if (err.status === 403) setError('Game-account signup is not available right now.')
|
||||
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
|
||||
else setError(err.message || 'Could not create the account right now.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
{!compact && (
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Choose the username and password you’ll type into the game client. These are your
|
||||
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
|
||||
</p>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">Game account name</span>
|
||||
<input
|
||||
type="text" autoComplete="off" value={account}
|
||||
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Game password</span>
|
||||
<input
|
||||
type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : 'Create game account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from './PageState.jsx'
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Shared game-account linking + character roster, used by both the player portal
|
||||
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||||
// maps a serial to the route for that character's sheet.
|
||||
// maps a serial to the route for that character's sheet. `readOnly` drops the
|
||||
// link forms and self-voice copy for the admin case where staff view *another*
|
||||
// user's accounts (no `scope.link`) at /admin/users/:id.
|
||||
|
||||
function LinkForm({ scope, onLinked, compact }) {
|
||||
const [code, setCode] = useState('')
|
||||
@@ -105,33 +110,90 @@ function AccountRoster({ scope, account, charTo }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo }) {
|
||||
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||||
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||||
function UnlinkButton({ account, onUnlink }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
async function go() {
|
||||
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await onUnlink(account)
|
||||
} catch (err) {
|
||||
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.'))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
// Whether the site currently offers game-account creation (public flag). Only
|
||||
// relevant for the self-service (non-readOnly) view with a createAccount scope.
|
||||
const [signupOk, setSignupOk] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setAccounts(await scope.accounts())
|
||||
} catch {
|
||||
setError('Could not load your game accounts.')
|
||||
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||||
}
|
||||
}, [scope])
|
||||
}, [scope, readOnly])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly || !scope.createAccount) return
|
||||
let active = true
|
||||
api.publicSettings()
|
||||
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||
.catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [readOnly, scope])
|
||||
|
||||
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return <Loading />
|
||||
|
||||
// Not linked yet — prompt to link.
|
||||
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||||
// empty state; otherwise it's the link-your-account prompt.
|
||||
if (accounts.length === 0) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
This user has not linked a game account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
You haven’t linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<LinkForm scope={scope} onLinked={load} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<LinkForm scope={scope} onLinked={load} />
|
||||
</div>
|
||||
{canCreate && (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -141,16 +203,28 @@ export default function GameAccounts({ scope, charTo }) {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
{accounts.map((a) => (
|
||||
<section key={a.account}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
{a.account}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
{a.account}
|
||||
</div>
|
||||
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||||
</div>
|
||||
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||||
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||
</section>
|
||||
))}
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||
<LinkForm scope={scope} onLinked={load} compact />
|
||||
</section>
|
||||
{!readOnly && (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||
<LinkForm scope={scope} onLinked={load} compact />
|
||||
{canCreate && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||
import { bucketize } from '../data/regionBuckets.js'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
||||
// then keeps the total + region breakdown current from the presence.online SSE
|
||||
// kind. The raw byRegion map is rolled up into display buckets (see
|
||||
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
||||
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||
|
||||
export default function PlayersOnline() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.presence())
|
||||
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
||||
|
||||
// The freshest snapshot wins: the newest buffered presence.online event, else
|
||||
// the initial fetch.
|
||||
const snapshot = events[0] || data
|
||||
|
||||
const { total, rows } = useMemo(() => {
|
||||
const count = Number(snapshot?.count) || 0
|
||||
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
||||
return { total: count, rows: bucketRows }
|
||||
}, [snapshot])
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Players online
|
||||
</span>
|
||||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{loading ? '—' : total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||
Population is unavailable right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
<span>{r.label}</span>
|
||||
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
84
client/src/components/ShardAccountActions.jsx
Normal file
84
client/src/components/ShardAccountActions.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
|
||||
// game account. Reused wherever a linked account or character is shown to staff:
|
||||
// the admin user-detail account list and the character sheet. Self-gates on role
|
||||
// (admin/moderator) so it is safe to render inside components that players also
|
||||
// see — a player never gets the controls, and the API enforces the same gate.
|
||||
//
|
||||
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
|
||||
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
|
||||
// confirm with an optional duration + reason before it fires.
|
||||
export default function ShardAccountActions({ account, style }) {
|
||||
const { user } = useAuth()
|
||||
const [busy, setBusy] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [banOpen, setBanOpen] = useState(false)
|
||||
const [durationSec, setDurationSec] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
// Only staff who can actually use the write plane see the controls.
|
||||
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
|
||||
|
||||
async function run(label, fn, done) {
|
||||
setBusy(label); setOk(''); setErr('')
|
||||
try {
|
||||
const r = await fn()
|
||||
setOk(done(r))
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const kick = () =>
|
||||
run('kick', () => api.admin.shardOps.kick({ account }), (r) =>
|
||||
`Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`,
|
||||
)
|
||||
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
|
||||
const ban = () =>
|
||||
run('ban', () =>
|
||||
api.admin.shardOps.ban({
|
||||
account,
|
||||
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
() => {
|
||||
setBanOpen(false)
|
||||
return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`
|
||||
})
|
||||
|
||||
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
||||
|
||||
return (
|
||||
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
|
||||
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
|
||||
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
|
||||
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
|
||||
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||
</div>
|
||||
|
||||
{banOpen && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Duration (sec, blank = permanent)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
|
||||
</label>
|
||||
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
|
||||
<span className="field-label">Reason (optional)</span>
|
||||
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||
</label>
|
||||
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
|
||||
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,34 +2,50 @@ import { Link } from 'react-router-dom'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
export default function SiteFooter() {
|
||||
const { contactEmail } = useSite()
|
||||
const { contactEmail, siteTitle } = useSite()
|
||||
return (
|
||||
<footer
|
||||
className="sans"
|
||||
className="sans site-footer"
|
||||
style={{
|
||||
borderTop: '1px solid var(--line)',
|
||||
padding: '28px 16px',
|
||||
color: 'var(--muted)',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.9rem',
|
||||
background: 'rgba(9,13,18,0.6)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<span>UOMysticmoon is an independent private shard project.</span>
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
</a>
|
||||
·
|
||||
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Shard Status
|
||||
</Link>
|
||||
·
|
||||
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</span>
|
||||
<div className="site-footer-inner">
|
||||
<div className="site-footer-badge">
|
||||
<img src="/assets/img/runic-emblem.png" alt="" aria-hidden="true" />
|
||||
<span>
|
||||
Powered by
|
||||
<br />
|
||||
<a
|
||||
href="https://gitea.whitlocktech.com/RunicGateway"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="site-footer-brand-link"
|
||||
>
|
||||
<strong>Runic Gateway</strong>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div className="site-footer-info">
|
||||
<span>{siteTitle} is an independent private shard project.</span>
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
</a>
|
||||
·
|
||||
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Shard Status
|
||||
</Link>
|
||||
·
|
||||
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
// One consistent top nav for the whole public site. Every page gets the same
|
||||
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||
@@ -12,6 +13,10 @@ const NAV = [
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard' },
|
||||
{ label: 'Champions', to: '/site/champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds' },
|
||||
{ label: 'Governors', to: '/site/governors' },
|
||||
{ label: 'Houses', to: '/site/houses' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
@@ -23,6 +28,7 @@ const linkStyle = ({ isActive }) => ({
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
const account =
|
||||
@@ -53,7 +59,7 @@ export default function SiteHeader() {
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||
>
|
||||
<MoonDot />
|
||||
UOMysticmoon
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{NAV.map((l) => (
|
||||
|
||||
@@ -23,13 +23,24 @@ export function SiteProvider({ children }) {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const brand = settings.brand || {}
|
||||
|
||||
// Apply the instance accent color to the CSS variable the theme is built on,
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild).
|
||||
useEffect(() => {
|
||||
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
||||
}, [brand.accent])
|
||||
|
||||
const value = {
|
||||
settings,
|
||||
loading,
|
||||
refresh,
|
||||
brand,
|
||||
mode: settings.site_mode || 'live',
|
||||
siteTitle: settings.site_title || 'UOMysticmoon',
|
||||
contactEmail: settings.contact_email || 'UOMysticmoon@gmail.com',
|
||||
siteTitle: brand.name || settings.site_title || 'Runic Gateway',
|
||||
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
|
||||
contactEmail: brand.contactEmail || settings.contact_email || '',
|
||||
heroImage: brand.hero || '/assets/img/runic-emblem.png',
|
||||
}
|
||||
|
||||
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
|
||||
|
||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
|
||||
// emoji sigil + a ring colour — enough to make the Governors board and the
|
||||
// governor badge read as distinct "crests" today, swappable for real artwork
|
||||
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
|
||||
// public path) onto an entry and update CityCrest to prefer it.
|
||||
//
|
||||
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
|
||||
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
|
||||
|
||||
export const CITY_CRESTS = {
|
||||
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
|
||||
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
|
||||
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
|
||||
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
|
||||
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
|
||||
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
|
||||
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
|
||||
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
|
||||
}
|
||||
|
||||
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
|
||||
|
||||
// Look up a crest by the raw city key, tolerating spacing variants
|
||||
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
|
||||
export function crestFor(city) {
|
||||
if (!city) return FALLBACK
|
||||
const key = String(city).replace(/\s+/g, '')
|
||||
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
|
||||
if (crest) return crest
|
||||
return { ...FALLBACK, label: String(city) }
|
||||
}
|
||||
62
client/src/data/regionBuckets.js
Normal file
62
client/src/data/regionBuckets.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
|
||||
// regions) up into a handful of labelled display buckets for the "Players Online"
|
||||
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
|
||||
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||
// so the bucket counts always reconcile to the true total.
|
||||
|
||||
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||
export const BUCKETS = [
|
||||
{
|
||||
id: 'britain',
|
||||
label: 'Britain',
|
||||
// Passthrough for the capital + its immediate surrounds.
|
||||
match: (r) => /^britain/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'towns',
|
||||
label: 'Towns',
|
||||
// The other named cities/towns.
|
||||
match: (r) =>
|
||||
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test(
|
||||
r,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'dungeons',
|
||||
label: 'Dungeons',
|
||||
match: (r) =>
|
||||
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
|
||||
r,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'housing',
|
||||
label: 'Housing',
|
||||
// House regions expose themselves as named house/townhouse regions.
|
||||
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'wilderness',
|
||||
label: 'Wilderness',
|
||||
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
|
||||
match: () => true,
|
||||
},
|
||||
]
|
||||
|
||||
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
|
||||
// order, dropping empty buckets, with the summed total also returned.
|
||||
export function bucketize(byRegion = {}) {
|
||||
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
|
||||
let total = 0
|
||||
for (const [region, n] of Object.entries(byRegion || {})) {
|
||||
const count = Number(n) || 0
|
||||
total += count
|
||||
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
|
||||
totals.set(bucket.id, totals.get(bucket.id) + count)
|
||||
}
|
||||
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
|
||||
(r) => r.count > 0,
|
||||
)
|
||||
return { rows, total }
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
// Shared hero-layout helpers used by the public portal and the admin editor.
|
||||
|
||||
export const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
|
||||
// Runic Gateway default hero emblem; the instance hero image (BRAND_HERO)
|
||||
// overrides it at runtime, threaded in as `defaultImage` by the portal.
|
||||
export const DEFAULT_HERO_IMAGE = '/assets/img/runic-emblem.png'
|
||||
|
||||
// The original hand-tuned multi-gradient hero background (used only for the
|
||||
// untouched default so the live page is byte-for-byte unchanged until edited).
|
||||
export const HERO_BG =
|
||||
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" +
|
||||
DEFAULT_HERO_IMAGE +
|
||||
"')"
|
||||
// Default hero background: the emblem centered behind the text as a medallion,
|
||||
// under a symmetric dark overlay tuned to keep centered hero copy legible.
|
||||
// Two layers (overlay gradient + image) so the per-layer background-size in
|
||||
// `heroBackground` can contain the square emblem while the overlay stays full-bleed.
|
||||
export function heroBgStack(image) {
|
||||
return (
|
||||
'linear-gradient(180deg,rgba(11,15,20,0.62) 0%,rgba(11,15,20,0.48) 38%,rgba(11,15,20,0.52) 58%,rgba(11,15,20,0.86) 100%),' +
|
||||
"url('" +
|
||||
(image || DEFAULT_HERO_IMAGE) +
|
||||
"')"
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the emblem fully visible and centered, capped so it never overflows a
|
||||
// narrow viewport; the overlay layer covers.
|
||||
export const HERO_DEFAULT_SIZE = 'cover, min(74vh, 640px, 86vw)'
|
||||
export const HERO_DEFAULT_POSITION = 'center, center'
|
||||
|
||||
export const HERO_BG = heroBgStack(DEFAULT_HERO_IMAGE)
|
||||
|
||||
// Single-stop dark overlay driven by the editor's opacity slider.
|
||||
export function buildOverlay(opacity) {
|
||||
@@ -16,15 +31,23 @@ export function buildOverlay(opacity) {
|
||||
|
||||
// Background style for a layout. When `isDefault` and no custom image is set, use
|
||||
// the exact original gradient stack; otherwise compose the overlay over the image.
|
||||
export function heroBackground(layout, { isDefault = false } = {}) {
|
||||
export function heroBackground(layout, { isDefault = false, defaultImage } = {}) {
|
||||
const bg = layout.background || {}
|
||||
const backgroundImage =
|
||||
isDefault && !bg.image_url
|
||||
? HERO_BG
|
||||
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')`
|
||||
const fallback = defaultImage || DEFAULT_HERO_IMAGE
|
||||
// Untouched default: emblem contained + centered behind the text (per-layer
|
||||
// size/position so the overlay stays full-bleed while the square emblem fits).
|
||||
if (isDefault && !bg.image_url) {
|
||||
return {
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: heroBgStack(fallback),
|
||||
backgroundPosition: HERO_DEFAULT_POSITION,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: HERO_DEFAULT_SIZE,
|
||||
}
|
||||
}
|
||||
return {
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage,
|
||||
backgroundImage: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || fallback}')`,
|
||||
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: bg.size || 'cover',
|
||||
@@ -44,7 +67,7 @@ export function parseLayout(str) {
|
||||
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
|
||||
// staff publish their own. Font sizes use the existing clamp() strings so the
|
||||
// default stays responsive (editor-created text uses px).
|
||||
export function defaultLayout(teaser) {
|
||||
export function defaultLayout(teaser, name = 'Runic Gateway') {
|
||||
return {
|
||||
version: 1,
|
||||
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
|
||||
@@ -62,7 +85,7 @@ export function defaultLayout(teaser) {
|
||||
width: 760,
|
||||
lines: [
|
||||
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
||||
{ text: 'UOMysticmoon', tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
||||
{ text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
||||
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
||||
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||
],
|
||||
|
||||
@@ -45,6 +45,24 @@ export function describe(ev) {
|
||||
return 'Shard shut down'
|
||||
case 'server.crashed':
|
||||
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
|
||||
case 'champ.update': {
|
||||
const where = p.name || p.type || 'A champion spawn'
|
||||
if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}`
|
||||
if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}`
|
||||
if (p.status === 'cooldown') return `${where} is on cooldown`
|
||||
return `${where} is ${p.status || 'idle'}`
|
||||
}
|
||||
case 'champ.remove':
|
||||
return `A champion spawn ended`
|
||||
// Support (help-page) queue + in-game moderation (admin channel only)
|
||||
case 'page.new':
|
||||
return `New ${p.type || 'help'} page from ${nameOf(p.sender)}`
|
||||
case 'page.updated':
|
||||
return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}`
|
||||
case 'page.closed':
|
||||
return `Help page ${p.pageId || ''} closed`
|
||||
case 'admin.audit':
|
||||
return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}`
|
||||
// Staff / sensitive (admin channel only)
|
||||
case 'audit.set':
|
||||
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
|
||||
|
||||
@@ -63,12 +63,15 @@ const NAV = [
|
||||
title: 'Moderation',
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||
@@ -94,6 +97,8 @@ const TITLES = {
|
||||
'/admin/wiki': 'Wiki Pages',
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/shard-ops': 'In-Game Ops',
|
||||
'/admin/houses': 'House Registry',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
@@ -102,6 +107,7 @@ const TITLES = {
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
'/admin/invites': 'Invites',
|
||||
'/admin/account': 'Account Security',
|
||||
}
|
||||
|
||||
@@ -120,7 +126,7 @@ const navBtnBase = {
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { mode } = useSite()
|
||||
const { mode, siteTitle } = useSite()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
@@ -129,16 +135,20 @@ export default function AdminLayout() {
|
||||
? 'Moderation'
|
||||
: location.pathname.startsWith('/admin/characters')
|
||||
? 'My Characters'
|
||||
: 'Admin')
|
||||
: location.pathname.startsWith('/admin/users/')
|
||||
? 'User'
|
||||
: 'Admin')
|
||||
// The hero canvas editor needs room — let it use the full content width.
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||
|
||||
// Moderators only get the moderation section + their own account security.
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const isModerator = user?.role === 'moderator'
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
const visible = (item) => {
|
||||
if (item.roles && !item.roles.includes(user?.role)) return false
|
||||
if (isModerator) return item.to === '/admin/moderation' || item.to === '/admin/account'
|
||||
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
// Drop items the current role can't see, then drop any now-empty group so an
|
||||
@@ -176,7 +186,9 @@ export default function AdminLayout() {
|
||||
useEffect(() => {
|
||||
if (!isModerator) return
|
||||
const p = location.pathname
|
||||
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
|
||||
const allowed =
|
||||
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
|
||||
if (!allowed) {
|
||||
navigate('/admin/moderation', { replace: true })
|
||||
}
|
||||
}, [isModerator, location.pathname, navigate])
|
||||
@@ -213,7 +225,7 @@ export default function AdminLayout() {
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
UOMysticmoon
|
||||
{siteTitle}
|
||||
</div>
|
||||
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||
Admin
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||
@@ -15,9 +16,6 @@ const SSO_ERRORS = {
|
||||
error: 'Could not complete sign-in. Please try again.',
|
||||
}
|
||||
|
||||
const BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
|
||||
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
||||
// skip) so real users never fill it but naive scripted bots do. Name must match
|
||||
// the server's HONEYPOT_FIELD ('company').
|
||||
@@ -33,6 +31,8 @@ const honeypotStyle = {
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||
const { siteTitle, heroImage } = useSite()
|
||||
const BG = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dest = location.state?.from?.pathname || '/admin'
|
||||
@@ -151,7 +151,8 @@ export default function AdminLogin() {
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: BG,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'min(60vh, 520px)',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
@@ -160,7 +161,7 @@ export default function AdminLogin() {
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
UOMysticmoon
|
||||
{siteTitle}
|
||||
</h1>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
||||
Admin Panel
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AdminCharacter() {
|
||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} moderation />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
|
||||
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
|
||||
@@ -52,6 +53,7 @@ function StatusPanel({ config }) {
|
||||
}
|
||||
|
||||
export default function EmailDelivery() {
|
||||
const { siteTitle } = useSite()
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [senderName, setSenderName] = useState('')
|
||||
@@ -213,7 +215,7 @@ export default function EmailDelivery() {
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="UOMysticmoon"
|
||||
placeholder={siteTitle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal file
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
|
||||
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
|
||||
// live from the admin SSE channel (house.update / house.remove).
|
||||
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
function DecayBadge({ decay, isIdoc }) {
|
||||
const label = isIdoc ? 'IDOC' : decay
|
||||
if (!label) return null
|
||||
const tone = DECAY_TONE[label] || 'var(--muted)'
|
||||
return (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ownerLabel(h) {
|
||||
return h.ownerName || h.ownerAcct || null
|
||||
}
|
||||
|
||||
function HouseRow({ h }) {
|
||||
const owner = ownerLabel(h)
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.name || 'An unnamed house'}
|
||||
</strong>
|
||||
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{h.price != null && (
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
|
||||
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HousesAdmin() {
|
||||
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
|
||||
// Full registry deltas ride the admin SSE channel (never the public one).
|
||||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (!ev.serial) continue
|
||||
if (ev.kind === 'house.update') {
|
||||
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
|
||||
} else if (ev.kind === 'house.remove') {
|
||||
map.delete(ev.serial)
|
||||
} else if (ev.kind === 'house.decay') {
|
||||
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
|
||||
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the house registry." />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
|
||||
{board.length.toLocaleString()} houses
|
||||
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
|
||||
</p>
|
||||
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
|
||||
</div>
|
||||
{board.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
)}
|
||||
{board.length > 0 && filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
178
client/src/routes/admin/views/InvitesAdmin.jsx
Normal file
178
client/src/routes/admin/views/InvitesAdmin.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin email invites: send an invite at a chosen access level, see recent
|
||||
// invites and their status, revoke pending ones. When email delivery isn't
|
||||
// configured the create response hands back the accept link to copy manually.
|
||||
|
||||
const ROLES = ['player', 'moderator', 'editor', 'admin']
|
||||
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' }
|
||||
const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' }
|
||||
|
||||
function CopyLink({ url }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
} catch {
|
||||
/* clipboard blocked — the link is selectable in the box regardless */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||
<code
|
||||
onClick={(e) => { const r = document.createRange(); r.selectNodeContents(e.currentTarget); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r) }}
|
||||
style={{ flex: 1, wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)', cursor: 'text', fontSize: '0.8rem' }}
|
||||
>
|
||||
{url}
|
||||
</code>
|
||||
<button type="button" onClick={copy} className="btn btn-sq" style={{ flex: 'none' }}>
|
||||
{copied ? 'Copied ✓' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateInvite({ onCreated }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState('player')
|
||||
const [sendEmail, setSendEmail] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError }
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setError(''); setResult(null)
|
||||
if (!email.trim()) return setError('Enter an email address.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.admin.createInvite(email.trim(), role, sendEmail)
|
||||
setResult(res)
|
||||
setEmail('')
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not create the invite.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Email</span>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="person@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Access level</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className="select">
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: '0.85rem', color: 'var(--ink)', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={sendEmail} onChange={(e) => setSendEmail(e.target.checked)} />
|
||||
Email the invitation (otherwise just generate a link to share)
|
||||
</label>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{result && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
{result.emailed
|
||||
? 'Invitation emailed. You can also share this single-use link:'
|
||||
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
|
||||
</p>
|
||||
<CopyLink url={result.acceptUrl} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InvitesAdmin() {
|
||||
const [invites, setInvites] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setInvites(await api.admin.listInvites())
|
||||
} catch {
|
||||
setError('Could not load invites.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function revoke(id) {
|
||||
if (!window.confirm('Revoke this pending invitation?')) return
|
||||
try {
|
||||
await api.admin.revokeInvite(id)
|
||||
await load()
|
||||
} catch {
|
||||
/* surfaced by the row staying; keep it simple */
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<CreateInvite onCreated={load} />
|
||||
|
||||
{!invites ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Email</th>
|
||||
<th className="adm-th">Role</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Expires</th>
|
||||
<th className="adm-th">Created</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invites.length === 0 && (
|
||||
<tr><td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No invites yet.</td></tr>
|
||||
)}
|
||||
{invites.map((iv) => {
|
||||
const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status
|
||||
return (
|
||||
<tr key={iv.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>{iv.email}</td>
|
||||
<td className="adm-td"><span className={`badge ${ROLE_BADGE[iv.role] || 'badge-editor'}`}>{iv.role}</span></td>
|
||||
<td className="adm-td" style={{ color: STATUS_COLOR[iv.status] || 'var(--muted)', textTransform: 'capitalize' }}>{status}</td>
|
||||
<td className="adm-td dim">{dateTime(iv.expiresAt)}</td>
|
||||
<td className="adm-td dim">{dateTime(iv.createdAt)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
{iv.status === 'pending' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }} onClick={() => revoke(iv.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,18 @@ const FIELDS = [
|
||||
],
|
||||
fallback: 'disabled',
|
||||
},
|
||||
{
|
||||
key: 'game_account_signup',
|
||||
label: 'Game-account creation',
|
||||
help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
|
||||
options: [
|
||||
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||||
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||||
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||||
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||||
],
|
||||
fallback: 'disabled',
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsAdmin() {
|
||||
|
||||
269
client/src/routes/admin/views/ShardOps.jsx
Normal file
269
client/src/routes/admin/views/ShardOps.jsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||
import { describe } from '../../../lib/shardEvents.js'
|
||||
import { ago } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
|
||||
// unban) and the help-page support queue, plus a live audit log. Open to admins
|
||||
// and moderators. The acting staff member (`actor`) is attached server-side from
|
||||
// the session — nothing here sends it — so every action is attributable.
|
||||
|
||||
function Flash({ ok, err }) {
|
||||
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
|
||||
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Broadcast ────────────────────────────────────────────────────────────────
|
||||
function Broadcast() {
|
||||
const [text, setText] = useState('')
|
||||
const [hue, setHue] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function send() {
|
||||
if (!text.trim()) return setErr('Enter a message.')
|
||||
setBusy(true); setOk(''); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
|
||||
setOk('Broadcast sent.')
|
||||
setText('')
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not broadcast.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
A system message shown to everyone online right now.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Message</span>
|
||||
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
|
||||
</label>
|
||||
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||
<span className="field-label">Hue (optional)</span>
|
||||
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
|
||||
<Flash ok={ok} err={err} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
|
||||
function AccountActions() {
|
||||
const [account, setAccount] = useState('')
|
||||
const [durationSec, setDurationSec] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const acct = account.trim()
|
||||
function guard() {
|
||||
if (!acct) {
|
||||
setErr('Enter an account name.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function run(label, fn, done) {
|
||||
if (!guard()) return
|
||||
setBusy(label); setOk(''); setErr('')
|
||||
try {
|
||||
const r = await fn()
|
||||
setOk(done(r))
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const kick = () =>
|
||||
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`)
|
||||
const ban = () =>
|
||||
run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`)
|
||||
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Account</span>
|
||||
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'block', maxWidth: 200 }}>
|
||||
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
|
||||
</label>
|
||||
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
|
||||
<span className="field-label">Ban reason (optional)</span>
|
||||
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
|
||||
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
|
||||
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
|
||||
<Flash ok={ok} err={err} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Support (help-page) queue ────────────────────────────────────────────────
|
||||
function PageRow({ page, onDone }) {
|
||||
const [message, setMessage] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function respond(close) {
|
||||
if (!message.trim()) return setErr('Enter a reply first.')
|
||||
setBusy(close ? 'respond-close' : 'respond'); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not send.')
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
async function close() {
|
||||
setBusy('close'); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.closePage(page.pageId)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not close.')
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{page.sender?.name || page.pageId}
|
||||
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
|
||||
</div>
|
||||
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
|
||||
</div>
|
||||
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
|
||||
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
|
||||
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
|
||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportQueue() {
|
||||
const [pages, setPages] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPages(await api.admin.shardOps.pages())
|
||||
} catch {
|
||||
setErr('Could not load the support queue.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
pollRef.current = setInterval(load, 7000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
Open help pages from players. A reply reaches them in game (or on their next login).
|
||||
</p>
|
||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
||||
{pages == null ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||
) : pages.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audit log ────────────────────────────────────────────────────────────────
|
||||
// Seeded from the stored admin.audit history, then kept live from the admin SSE
|
||||
// channel (which carries every kind — we filter to admin.audit here).
|
||||
function AuditLog() {
|
||||
const [seed, setSeed] = useState([])
|
||||
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
|
||||
|
||||
useEffect(() => {
|
||||
api.admin.shardOps
|
||||
.audit(50)
|
||||
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
|
||||
.catch(() => setSeed([]))
|
||||
}, [])
|
||||
|
||||
// Live events on top; fall back to the seed for anything older than the live tail.
|
||||
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
|
||||
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
|
||||
{rows.map((e) => (
|
||||
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardOps() {
|
||||
return (
|
||||
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<Broadcast />
|
||||
<AccountActions />
|
||||
<SupportQueue />
|
||||
<AuditLog />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
182
client/src/routes/admin/views/UserDetail.jsx
Normal file
182
client/src/routes/admin/views/UserDetail.jsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { dateTime, ago } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../../components/VendorSales.jsx'
|
||||
|
||||
// Admin read-only view of one user's shard (uo-link) footprint: linked game
|
||||
// accounts + character rosters, currently-online characters, houses (incl.
|
||||
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
|
||||
// Reached from the Users table's "View" action; Edit stays a separate modal.
|
||||
|
||||
const ROLE_BADGE = {
|
||||
admin: 'badge-admin',
|
||||
editor: 'badge-editor',
|
||||
moderator: 'badge-moderator',
|
||||
player: 'badge-player',
|
||||
}
|
||||
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Currently-online characters on the user's accounts, with where they are. The
|
||||
// per-character Online/Offline badge lives in the roster; this adds location.
|
||||
function OnlineNow({ scope }) {
|
||||
const { data } = useAsync(() => scope.online(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Online now</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((c) => (
|
||||
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Shard "standing": city governorships held and guilds led by this user's
|
||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||
function Standing({ scope }) {
|
||||
const { data } = useAsync(() => scope.standing(), [scope])
|
||||
if (!data) return null
|
||||
const govs = data.governorOf || []
|
||||
const guilds = data.guildsLed || []
|
||||
if (govs.length === 0 && guilds.length === 0) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Standing</SectionTitle>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{govs.map((g) => (
|
||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||
Governor of {g.city}
|
||||
</span>
|
||||
))}
|
||||
{guilds.map((g) => (
|
||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||
function Houses({ scope }) {
|
||||
const { data } = useAsync(() => scope.houses(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Houses</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => (
|
||||
<li
|
||||
key={h.serial}
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{h.name || 'Unnamed house'}
|
||||
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||||
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ShardSections({ scope }) {
|
||||
return (
|
||||
<>
|
||||
<CharacterStats scope={scope} />
|
||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserDetail() {
|
||||
const { id } = useParams()
|
||||
// Memoize so the child components' effects (keyed on `scope`) don't refetch
|
||||
// on every render.
|
||||
const scope = useMemo(() => api.admin.userShard(id), [id])
|
||||
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load this user." />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||||
← Back to users
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||
{user.username}
|
||||
</span>
|
||||
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
|
||||
>
|
||||
{user.status || 'active'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
|
||||
{user.email && <span>{user.email}</span>}
|
||||
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
|
||||
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShardSections scope={scope} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
@@ -13,6 +14,7 @@ const ROLE_BADGE = {
|
||||
}
|
||||
|
||||
export default function UsersAdmin() {
|
||||
const navigate = useNavigate()
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
|
||||
@@ -64,8 +66,13 @@ export default function UsersAdmin() {
|
||||
</td>
|
||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<span className="link-accent" onClick={() => setEditing(u)}>
|
||||
Edit
|
||||
<span style={{ display: 'inline-flex', gap: 16, justifyContent: 'flex-end' }}>
|
||||
<span className="link-accent" onClick={() => navigate(`/admin/users/${u.id}`)}>
|
||||
View
|
||||
</span>
|
||||
<span className="link-accent" onClick={() => setEditing(u)}>
|
||||
Edit
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
132
client/src/routes/player/AcceptInvite.jsx
Normal file
132
client/src/routes/player/AcceptInvite.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
|
||||
|
||||
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
|
||||
// lets the invitee set a username + password (their email + role are pre-assigned),
|
||||
// creates the account at that role and logs them in. For a player invite it then
|
||||
// offers the built-in "create game account" step before sending them to the portal.
|
||||
export default function AcceptInvite() {
|
||||
const { token } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { refresh } = useAuth()
|
||||
|
||||
const [invite, setInvite] = useState(null) // { email, role }
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [signupOk, setSignupOk] = useState(false)
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [company, setCompany] = useState('') // honeypot
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.getInvite(token)
|
||||
.then((iv) => active && setInvite(iv))
|
||||
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
|
||||
api.publicSettings()
|
||||
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||
.catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [token])
|
||||
|
||||
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.acceptInvite(token, username.trim(), password, { company })
|
||||
await refresh() // pull the freshly-issued session into context
|
||||
setAccepted(true)
|
||||
// Staff invites are web-only — no game step; go straight in.
|
||||
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
|
||||
else if (err.status === 404) setError('This invitation is invalid or has expired.')
|
||||
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||||
else setError('Could not accept the invitation right now.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading / invalid ─────────────────────────────────────────────────────
|
||||
if (loadErr) {
|
||||
return (
|
||||
<PlayerShell subtitle="Invitation">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Go to sign in</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
if (!invite) {
|
||||
return (
|
||||
<PlayerShell subtitle="Invitation">
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Accepted: optional game-account step (player invites) ──────────────────
|
||||
if (accepted) {
|
||||
return (
|
||||
<PlayerShell subtitle="Set up your game account">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||||
</p>
|
||||
<CreateGameAccountForm
|
||||
submit={api.player.shard.createAccount}
|
||||
onCreated={() => navigate('/player', { replace: true })}
|
||||
/>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||||
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
|
||||
Skip for now →
|
||||
</button>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Accept form ────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<PlayerShell subtitle="Accept your invitation">
|
||||
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
You’ve been invited as <strong style={{ color: 'var(--head)' }}>{invite.role}</strong>
|
||||
{invite.email ? <> for <strong style={{ color: 'var(--head)' }}>{invite.email}</strong></> : null}. Choose a username and password to finish.
|
||||
</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Username</span>
|
||||
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Password</span>
|
||||
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||
</label>
|
||||
<div style={honeypotStyle} aria-hidden="true">
|
||||
<label>
|
||||
Company
|
||||
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||
{busy ? 'Creating…' : 'Accept & create account'}
|
||||
</button>
|
||||
</form>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,57 @@
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The logged-in player's characters. Shows the link prompt when no game account
|
||||
// is linked, otherwise their characters grouped by account (shared component),
|
||||
// plus their own recent vendor sales.
|
||||
// plus their own home status and recent vendor sales.
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
// The caller's own houses (home status). Only their own — never anyone else's.
|
||||
function MyHouses() {
|
||||
const { data } = useAsync(() => api.player.shard.houses(), [])
|
||||
if (!data || data.length === 0) return null
|
||||
return (
|
||||
<section style={{ marginTop: 30 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => {
|
||||
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
|
||||
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
|
||||
return (
|
||||
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
|
||||
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerCharacters() {
|
||||
return (
|
||||
<div>
|
||||
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||
<MyHouses />
|
||||
<VendorSales fetchSales={api.player.shard.sales} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
||||
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
||||
@@ -55,6 +56,7 @@ const navBtnBase = {
|
||||
|
||||
export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
@@ -84,7 +86,7 @@ export default function PlayerPortalLayout() {
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
UOMysticmoon
|
||||
{siteTitle}
|
||||
</div>
|
||||
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||
Player Portal
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
|
||||
const BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Centered card layout shared by the player login / register pages. `subtitle`
|
||||
// labels the card; `footer` is optional content under the card (e.g. cross-links).
|
||||
export default function PlayerShell({ subtitle, children, footer }) {
|
||||
const { siteTitle, heroImage } = useSite()
|
||||
const bg = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
@@ -16,9 +16,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
|
||||
padding: '40px 18px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: BG,
|
||||
backgroundImage: bg,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'min(60vh, 520px)',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
@@ -27,7 +28,7 @@ export default function PlayerShell({ subtitle, children, footer }) {
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
UOMysticmoon
|
||||
{siteTitle}
|
||||
</h1>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
||||
{subtitle}
|
||||
|
||||
@@ -3,14 +3,14 @@ import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
export default function About() {
|
||||
const { contactEmail } = useSite()
|
||||
const { contactEmail, siteShortName } = useSite()
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="About" title="About Mysticmoon" />
|
||||
<PageHeader eyebrow="About" title={`About ${siteShortName}`} />
|
||||
<div className="prose">
|
||||
<p>
|
||||
Mysticmoon is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
||||
{siteShortName} is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
||||
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
||||
worlds and the friendships made in them.
|
||||
</p>
|
||||
|
||||
203
client/src/routes/public/ChampSpawns.jsx
Normal file
203
client/src/routes/public/ChampSpawns.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useMemo } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
|
||||
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
|
||||
// families share the board, split by category into their own sections.
|
||||
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
|
||||
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
|
||||
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
|
||||
]
|
||||
|
||||
const STATUS_STYLE = {
|
||||
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
|
||||
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
|
||||
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
|
||||
}
|
||||
|
||||
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms)) return ''
|
||||
if (ms <= 0) return 'due'
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
return `in ${hrs}h`
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.68rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '3px 9px',
|
||||
borderRadius: 999,
|
||||
color: s.fg,
|
||||
background: s.bg,
|
||||
border: `1px solid ${s.border}`,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
|
||||
function Meter({ value, max, tone = 'var(--accent)' }) {
|
||||
if (!max) return null
|
||||
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
|
||||
return (
|
||||
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Category-specific middle line + meter for one spawn.
|
||||
function ChampDetail({ s }) {
|
||||
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
|
||||
if (s.category === 'sea') {
|
||||
return (
|
||||
<>
|
||||
<div className="sans" style={line}>
|
||||
<span>{s.boss || s.type}</span>
|
||||
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (s.category === 'mini') {
|
||||
return (
|
||||
<div className="sans" style={line}>
|
||||
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
|
||||
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// champion
|
||||
return (
|
||||
<>
|
||||
<div className="sans" style={line}>
|
||||
<span>
|
||||
Level {s.level ?? 0}
|
||||
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
||||
</span>
|
||||
<span>
|
||||
{s.status === 'cooldown'
|
||||
? until(s.restartAt) || 'restarting'
|
||||
: s.status === 'active'
|
||||
? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{s.status === 'active' && (
|
||||
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChampCard({ s }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.name || s.type || 'Spawn'}
|
||||
</strong>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
<ChampDetail s={s} />
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
|
||||
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChampSpawns() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.champs())
|
||||
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
|
||||
|
||||
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
|
||||
// buffered events oldest → newest (the buffer is newest-first) so live wins.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (!ev || !ev.serial) continue
|
||||
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
|
||||
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const byCategory = (id) =>
|
||||
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
|
||||
const activeCount = board.filter((s) => s.status === 'active').length
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the champion board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
|
||||
{activeCount} active · {board.length} tracked
|
||||
</p>
|
||||
{SECTIONS.map((sec) => {
|
||||
const rows = byCategory(sec.id)
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<section key={sec.id} style={{ marginBottom: 28 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
|
||||
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
186
client/src/routes/public/Governors.jsx
Normal file
186
client/src/routes/public/Governors.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { crestFor } from '../../data/cityCrests.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||
// City Loyalty system. Each city card links to its term history (look-back).
|
||||
const GOV_KINDS = new Set(['city.update'])
|
||||
|
||||
const PHASE = {
|
||||
none: null,
|
||||
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||
vote: { label: 'Voting', color: '#e6c26a' },
|
||||
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||
}
|
||||
|
||||
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `in ${hrs}h`
|
||||
return `in ${Math.round(hrs / 24)}d`
|
||||
}
|
||||
|
||||
function fmtDate(ms) {
|
||||
if (ms == null) return ''
|
||||
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function CityCrest({ city, size = 44 }) {
|
||||
const c = crestFor(city)
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||
}}
|
||||
>
|
||||
{c.sigil}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||
function TermHistory({ city }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { loading, error, data } = useAsync(
|
||||
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||
[open, city],
|
||||
)
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||
>
|
||||
{open ? 'Hide past governors' : 'Past governors →'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||
{data && data.length === 0 && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{data.map((t, i) => (
|
||||
<li key={i} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.governor?.name || 'Vacant'}
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CityCard({ c }) {
|
||||
const phase = PHASE[c.electionPhase] || null
|
||||
const gov = c.governor
|
||||
return (
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<CityCrest city={c.city} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||
{crestFor(c.city).label || c.city}
|
||||
</strong>
|
||||
{phase && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{phase.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||
{gov ? (
|
||||
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||
) : (
|
||||
'Seat vacant'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'}
|
||||
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TermHistory city={c.city} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Governors() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the governor board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
City Loyalty governance is not enabled on this shard.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
169
client/src/routes/public/Guilds.jsx
Normal file
169
client/src/routes/public/Guilds.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||
// joined" strip on top of the board.
|
||||
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||
|
||||
function Leader({ leader }) {
|
||||
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||
return <span>{leader.name}</span>
|
||||
}
|
||||
|
||||
function GuildRow({ g }) {
|
||||
return (
|
||||
<div
|
||||
className="panel"
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||
{g.abbr && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid rgba(201,162,75,0.4)',
|
||||
borderRadius: 5,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{g.abbr}
|
||||
</span>
|
||||
)}
|
||||
<strong
|
||||
className="display"
|
||||
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{g.name || 'A guild'}
|
||||
</strong>
|
||||
</div>
|
||||
{g.alliance && (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{g.alliance}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<div>
|
||||
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||
<span className="dim"> / {g.members ?? 0}</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
<Leader leader={g.leader} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Guilds() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
// Recent joins strip (newest first, deduped, capped).
|
||||
const joins = useMemo(
|
||||
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||
[events],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((g) =>
|
||||
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||
)
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the guild board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{joins.length > 0 && (
|
||||
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Recently joined
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{joins.map((j) => (
|
||||
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||
<span className="dim"> joined </span>
|
||||
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
||||
{board.length} guilds · {totalMembers.toLocaleString()} members
|
||||
</p>
|
||||
<input
|
||||
className="input sans"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search guilds…"
|
||||
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
91
client/src/routes/public/Houses.jsx
Normal file
91
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
||||
// price, decay detail and the full registry are staff-only (admin Houses view).
|
||||
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
||||
// house entering IDOC appears, one leaving it drops off.
|
||||
const HOUSE_KINDS = new Set(['house.decay'])
|
||||
|
||||
function HouseRow({ h }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.region || 'The wilderness'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
||||
IDOC
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Houses() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||
|
||||
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
||||
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind !== 'house.decay' || !ev.serial) continue
|
||||
if (String(ev.to).toUpperCase() === 'IDOC') {
|
||||
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
||||
} else {
|
||||
map.delete(ev.serial)
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the houses board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
||||
{board.length} in danger
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,12 @@ import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
const HERO_BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.55) 0%,rgba(11,15,20,0.74) 60%,rgba(11,15,20,0.92) 100%),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
|
||||
export default function Maintenance() {
|
||||
const { settings, contactEmail } = useSite()
|
||||
const { settings, contactEmail, siteShortName, heroImage } = useSite()
|
||||
const heroBg = `linear-gradient(180deg,rgba(11,15,20,0.55) 0%,rgba(11,15,20,0.74) 60%,rgba(11,15,20,0.92) 100%),url('${heroImage}')`
|
||||
const message =
|
||||
settings.maintenance_message ||
|
||||
'Mysticmoon is in maintenance while we shape its towns, roads, and dungeons. The gates will open soon. Until then, follow along as the world wakes.'
|
||||
`${siteShortName} is in maintenance while we shape its towns, roads, and dungeons. The gates will open soon. Until then, follow along as the world wakes.`
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -22,10 +20,10 @@ export default function Maintenance() {
|
||||
padding: '80px max(18px,calc((100% - 760px)/2))',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: HERO_BG,
|
||||
backgroundImage: heroBg,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'cover',
|
||||
backgroundSize: 'min(60vh, 520px)',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
||||
|
||||
@@ -4,9 +4,11 @@ import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { longDate } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
export default function News() {
|
||||
const { loading, error, data } = useAsync(() => api.posts('news'))
|
||||
const { siteShortName } = useSite()
|
||||
const posts = data || []
|
||||
|
||||
return (
|
||||
@@ -15,7 +17,7 @@ export default function News() {
|
||||
<PageHeader
|
||||
eyebrow="Development"
|
||||
title="News & Updates"
|
||||
lead="Progress notes and announcements as Mysticmoon takes shape."
|
||||
lead={`Progress notes and announcements as ${siteShortName} takes shape.`}
|
||||
/>
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{loading && <Loading />}
|
||||
|
||||
@@ -9,10 +9,10 @@ import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout
|
||||
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
|
||||
|
||||
export default function Portal() {
|
||||
const { settings } = useSite()
|
||||
const { settings, siteShortName, siteTitle, heroImage } = useSite()
|
||||
const teaser =
|
||||
settings.homepage_teaser ||
|
||||
'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.'
|
||||
`${siteShortName} is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.`
|
||||
|
||||
// Published layout (public). Falls back to the pre-populated default if missing,
|
||||
// malformed, the wrong version, or empty — so the hero is never blank.
|
||||
@@ -34,13 +34,13 @@ export default function Portal() {
|
||||
}, [])
|
||||
|
||||
const active = (PREVIEW && draft) || (published && published.elements.length ? published : null)
|
||||
const layout = active || defaultLayout(teaser)
|
||||
const layout = active || defaultLayout(teaser, siteTitle)
|
||||
const isDefault = !active
|
||||
const bgStyle = heroBackground(layout, { isDefault })
|
||||
const bgStyle = heroBackground(layout, { isDefault, defaultImage: heroImage })
|
||||
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PublicLayout header={false}>
|
||||
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{PREVIEW && draft && (
|
||||
<div
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||
|
||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||
function Sparkline({ series }) {
|
||||
@@ -108,12 +109,16 @@ export default function Shard() {
|
||||
</section>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
|
||||
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||
</section>
|
||||
|
||||
{/* Live players-online breakdown (total + region buckets) */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<PlayersOnline />
|
||||
</div>
|
||||
|
||||
{/* Staff online — linked staff accounts only, with location */}
|
||||
<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 }}>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
const CARDS = [
|
||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
||||
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
|
||||
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
|
||||
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
|
||||
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the Mysticmoon world.', to: '/wiki' },
|
||||
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the game world.', to: '/wiki' },
|
||||
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
||||
]
|
||||
|
||||
export default function Website() {
|
||||
const { siteShortName } = useSite()
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<PageHeader
|
||||
center
|
||||
eyebrow="Public portal"
|
||||
title="Mysticmoon Website"
|
||||
title={`${siteShortName} Website`}
|
||||
lead="A home for gameplay pictures, development updates, community posts, monthly newsletters, and weekly Five on Friday notes."
|
||||
/>
|
||||
<section className="grid-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
function SearchBox({ initial, onSubmit }) {
|
||||
const [term, setTerm] = useState(initial || '')
|
||||
@@ -61,6 +62,7 @@ function PageCard({ page }) {
|
||||
}
|
||||
|
||||
export default function Wiki() {
|
||||
const { siteShortName } = useSite()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const activeCategory = searchParams.get('category')
|
||||
const activeTag = searchParams.get('tag')
|
||||
@@ -92,7 +94,7 @@ export default function Wiki() {
|
||||
<PageHeader
|
||||
center
|
||||
eyebrow="Knowledge base"
|
||||
title="Mysticmoon Wiki"
|
||||
title={`${siteShortName} Wiki`}
|
||||
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
|
||||
/>
|
||||
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* ===== UOMysticmoon design tokens (from the Claude Design handoff) ===== */
|
||||
/* ===== Runic Gateway design tokens (from the Claude Design handoff) ===== */
|
||||
:root {
|
||||
--bg: #0e1318;
|
||||
--bg-deep: #0b0f14;
|
||||
@@ -1009,6 +1009,74 @@ button[disabled] {
|
||||
border-color: #8a4b47;
|
||||
}
|
||||
|
||||
/* Site footer: powered-by badge left-justified, info centered. */
|
||||
.site-footer-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.site-footer-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.site-footer-badge img {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 6px rgba(127, 153, 189, 0.35));
|
||||
}
|
||||
.site-footer-badge span {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
text-align: left;
|
||||
}
|
||||
.site-footer-badge strong {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.site-footer-brand-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.site-footer-brand-link:hover strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
.site-footer-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Pin the badge to the left while the info column stays visually centered. */
|
||||
@media (min-width: 641px) {
|
||||
.site-footer-badge {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.site-footer-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
.site-footer-badge img {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Draft-preview banner on the public renderer. */
|
||||
.page-preview-banner {
|
||||
border: 1px solid var(--accent);
|
||||
|
||||
24
docker-compose.dev.yml
Normal file
24
docker-compose.dev.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Development overlay — build the app + bot images locally instead of pulling the
|
||||
# prebuilt ones from the Gitea registry.
|
||||
#
|
||||
# The base docker-compose.yml is production-shaped (image: only, no build:), so a
|
||||
# production host can never accidentally build — it only pulls. Use this overlay
|
||||
# EXPLICITLY for local work (it is not auto-loaded like docker-compose.override.yml
|
||||
# would be):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
#
|
||||
# Production stays:
|
||||
#
|
||||
# docker compose pull && docker compose up -d
|
||||
#
|
||||
# The `image:` tags inherited from the base file double as the local build tags,
|
||||
# so a built image and a pulled one are interchangeable.
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: bot/Dockerfile
|
||||
@@ -21,7 +21,14 @@ services:
|
||||
# - "3306:3306"
|
||||
|
||||
app:
|
||||
build: .
|
||||
# Prebuilt image from the Gitea registry (published by
|
||||
# .gitea/workflows/build-images.yml on every merge to main). This file is
|
||||
# production-shaped — image only, NO build: — so a production host can only
|
||||
# ever pull, never accidentally build. IMAGE_TAG defaults to `latest`; pin a
|
||||
# specific build for a reproducible deploy / rollback, e.g.
|
||||
# IMAGE_TAG=sha-042a151 (see .env / .env.example). To build locally instead,
|
||||
# overlay docker-compose.dev.yml (see README).
|
||||
image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -35,6 +42,11 @@ services:
|
||||
- uploads:/app/uploads
|
||||
# Bind-mount logs to the host so app.log is directly readable at ./logs/
|
||||
- ./logs:/app/logs
|
||||
# Instance branding assets (logo/hero/favicon), served at /brand when
|
||||
# BRAND_LOGO/HERO/FAVICON point there. Optional — defaults are baked into
|
||||
# the image, so this mount only matters for custom brand images. Create
|
||||
# ./brand/ on the host and drop assets in; read-only in the container.
|
||||
- ./brand:/app/brand:ro
|
||||
# Only the PUBLIC API port (3000) is published. The internal server<->bot
|
||||
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
|
||||
# stays reachable only over the private compose network — Pangolin/the public
|
||||
@@ -44,9 +56,9 @@ services:
|
||||
- "3000:3000"
|
||||
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: bot/Dockerfile
|
||||
# Same as app: prebuilt bot image, pulled in production. Build locally via
|
||||
# docker-compose.dev.yml.
|
||||
image: gitea.whitlocktech.com/runicgateway/website-bot:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "uomysticmoon-website",
|
||||
"name": "runic-gateway-website",
|
||||
"version": "1.0.0",
|
||||
"description": "UOMysticmoon — public site, wiki, and admin panel for a private Ultima Online shard",
|
||||
"description": "Runic Gateway — public site, wiki, and admin panel for a private Ultima Online shard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"install-server": "npm install --prefix server",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ─── UOMysticmoon server — local dev environment ───
|
||||
# ─── Runic Gateway server — local dev environment ───
|
||||
# Copy to server/.env for running `npm run dev` outside Docker.
|
||||
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||
|
||||
@@ -18,14 +18,14 @@ LOG_TO_FILE=true # set false for console-only
|
||||
# Point at a local or Dockerized MariaDB
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
DB_NAME=runic_gateway
|
||||
DB_USER=runic
|
||||
DB_PASSWORD=change-me-db-password
|
||||
|
||||
JWT_SECRET=dev-only-change-me
|
||||
JWT_EXPIRES_IN=1d
|
||||
COOKIE_SECURE=auto
|
||||
COOKIE_NAME=uomm_token
|
||||
COOKIE_NAME=rg_token
|
||||
|
||||
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
||||
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
||||
@@ -62,7 +62,8 @@ TRUST_PROXY=1
|
||||
DEBUG_TRUST_PROXY=0
|
||||
|
||||
# Optional TOTP two-factor (opt-in per user).
|
||||
TOTP_ISSUER=UOMysticmoon
|
||||
# TOTP_ISSUER defaults to BRAND_NAME; BRAND_* live in the root .env (see root .env.example)
|
||||
TOTP_ISSUER=Runic Gateway
|
||||
# How long the "password verified, awaiting code" step stays valid.
|
||||
TOTP_CHALLENGE_TTL=5m
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- UOMysticmoon database schema (MariaDB)
|
||||
-- Runic Gateway database schema (MariaDB)
|
||||
-- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a
|
||||
-- fresh volume, and idempotently by ensureSchema() on every server boot.
|
||||
|
||||
@@ -383,6 +383,159 @@ CREATE TABLE IF NOT EXISTS shard_account_links (
|
||||
INDEX idx_shard_links_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current champion-spawn board, upserted on champ.update and removed on
|
||||
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
|
||||
-- the public Champions page (and its live deltas) survive a shard outage, the
|
||||
-- same way shard_online / shard_houses do. Three families share one table, told
|
||||
-- apart by `category` (champion | mini | sea); category-specific fields (level,
|
||||
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
|
||||
-- not have to model every variant.
|
||||
CREATE TABLE IF NOT EXISTS shard_champs (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
|
||||
category VARCHAR(16) NULL, -- champion | mini | sea
|
||||
type VARCHAR(80) NULL,
|
||||
name VARCHAR(120) NULL,
|
||||
status VARCHAR(16) NULL, -- active | cooldown | dormant
|
||||
active TINYINT(1) NOT NULL DEFAULT 0,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
boss_up TINYINT(1) NOT NULL DEFAULT 0,
|
||||
payload JSON NOT NULL, -- the full champ.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_champs_category (category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
|
||||
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
|
||||
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
|
||||
-- player). Staff-only data — served on the admin channel, never public.
|
||||
CREATE TABLE IF NOT EXISTS shard_pages (
|
||||
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
|
||||
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
|
||||
sender_name VARCHAR(120) NULL,
|
||||
sender_acct VARCHAR(120) NULL,
|
||||
web_id INT NULL, -- linked website user id, if any
|
||||
message TEXT NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
|
||||
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
|
||||
handler VARCHAR(120) NULL,
|
||||
payload JSON NOT NULL, -- the full page.new/updated object
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_pages_handled (handled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
|
||||
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
|
||||
-- actor object flattened into leader_* columns; the full event is kept in
|
||||
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
|
||||
-- projection into our store so the public Guilds page survives a shard outage.
|
||||
CREATE TABLE IF NOT EXISTS shard_guilds (
|
||||
id INT NOT NULL PRIMARY KEY, -- in-game guild id
|
||||
name VARCHAR(120) NULL,
|
||||
abbr VARCHAR(24) NULL,
|
||||
members INT NULL,
|
||||
online INT NULL,
|
||||
alliance VARCHAR(120) NULL,
|
||||
leader_serial VARCHAR(20) NULL,
|
||||
leader_name VARCHAR(120) NULL,
|
||||
leader_acct VARCHAR(120) NULL,
|
||||
leader_web_id INT NULL,
|
||||
payload JSON NOT NULL, -- the full guild.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_guilds_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
|
||||
-- city.update (full-state, emitted only on change; there is no remove event since
|
||||
-- the set of cities is fixed). governor / governorElect are actor objects
|
||||
-- flattened into columns; the full event is kept in `payload`. Empty on shards
|
||||
-- that do not run the City Loyalty system.
|
||||
CREATE TABLE IF NOT EXISTS shard_governors (
|
||||
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
|
||||
governor_serial VARCHAR(20) NULL,
|
||||
governor_name VARCHAR(120) NULL,
|
||||
governor_acct VARCHAR(120) NULL,
|
||||
governor_web_id INT NULL,
|
||||
elect_serial VARCHAR(20) NULL,
|
||||
elect_name VARCHAR(120) NULL,
|
||||
elect_acct VARCHAR(120) NULL,
|
||||
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
|
||||
candidates INT NULL,
|
||||
auto_pick_at DATETIME NULL,
|
||||
payload JSON NOT NULL, -- the full city.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Governor term history — the "who governed when" ledger behind the Governors
|
||||
-- board. Captured from day one (history cannot be backfilled) on every observed
|
||||
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
|
||||
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
|
||||
-- COUNT and election phase, not per-candidate tallies, so we record who governed
|
||||
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
|
||||
-- were all the governors of Britain?") reads this table.
|
||||
CREATE TABLE IF NOT EXISTS shard_governor_terms (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
city VARCHAR(40) NOT NULL,
|
||||
governor_serial VARCHAR(20) NULL,
|
||||
governor_name VARCHAR(120) NULL,
|
||||
governor_acct VARCHAR(120) NULL,
|
||||
governor_web_id INT NULL,
|
||||
started_at BIGINT NOT NULL, -- term start, epoch ms
|
||||
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
|
||||
votes INT NULL, -- not in the feed (reserved)
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_gov_terms_city (city, started_at),
|
||||
INDEX idx_shard_gov_terms_open (city, ended_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
|
||||
-- latest presence.online aggregate: total count plus per-facet and per-region
|
||||
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
|
||||
-- is the rolled-up headcount the public "Players Online" widget renders. The
|
||||
-- time series, if ever needed, is available from GET /history?kind=presence.online.
|
||||
CREATE TABLE IF NOT EXISTS shard_presence (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
count INT NOT NULL DEFAULT 0,
|
||||
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
|
||||
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
|
||||
t BIGINT NULL, -- snapshot time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
||||
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
||||
-- which creates their website user at that role (and optionally a linked game
|
||||
-- account). Only the sha256 hash of the opaque token is stored — a DB read never
|
||||
-- yields a usable invite link, same as mobile_refresh_tokens. status tracks the
|
||||
-- lifecycle; accepted_user_id back-points at the created user. Single-use +
|
||||
-- expiring (enforced in the model on top of expires_at).
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||
email VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player',
|
||||
status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
|
||||
invited_by INT NULL, -- staff user who sent it
|
||||
accepted_user_id INT NULL, -- the user created on accept
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
accepted_at DATETIME NULL,
|
||||
CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_user_invites_email (email),
|
||||
INDEX idx_user_invites_status (status, expires_at)
|
||||
) 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
|
||||
@@ -707,6 +860,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
||||
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
||||
-- so the system behaves exactly as today until an admin opts in.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
||||
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
|
||||
-- may provision a linked game account from the site. Default off; the shard's own
|
||||
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
|
||||
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||
@@ -724,3 +881,20 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title,
|
||||
-- already keeps the two tables consistent.
|
||||
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
|
||||
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;
|
||||
|
||||
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
|
||||
-- fields than the house.decay transition feed shard_houses was built for. Rather
|
||||
-- than a second table for one entity, extend shard_houses: house.update writes the
|
||||
-- registry columns below (owner display name, co-owner/friend counts, placement
|
||||
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
|
||||
-- upsert only touches its own columns, so the two feeds never clobber each other.
|
||||
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
||||
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
|
||||
-- so the public Houses browser can list registered houses without pulling in rows
|
||||
-- we only ever saw an IDOC transition for.
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ const settingsDb = require('../src/model/settings/settings.db')
|
||||
const wikiDb = require('../src/model/wiki/wiki.db')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const { ensureSchema, close } = require('../src/utils/db')
|
||||
const brand = require('../src/config/brand')
|
||||
|
||||
const log = require('../src/utils/logger')('seed')
|
||||
|
||||
@@ -13,20 +14,20 @@ const DEFAULT_SETTINGS = {
|
||||
site_mode_changed_at: '',
|
||||
site_mode_changed_by: '',
|
||||
maintenance_message:
|
||||
'Mysticmoon is being shaped beneath a midnight sky. The site will return soon.',
|
||||
`${brand.shortName} is being shaped beneath a midnight sky. The site will return soon.`,
|
||||
status_message: 'In progress.',
|
||||
homepage_teaser:
|
||||
'Mysticmoon is still being shaped beneath a midnight sky. A quiet preview for ' +
|
||||
`${brand.shortName} is still being shaped beneath a midnight sky. A quiet preview for ` +
|
||||
'future news, screenshots, guides, and community notes as the world comes online.',
|
||||
contact_email: process.env.CONTACT_TO || 'UOMysticmoon@gmail.com',
|
||||
site_title: 'UOMysticmoon',
|
||||
contact_email: brand.contactEmail,
|
||||
site_title: brand.name,
|
||||
}
|
||||
|
||||
// Starter wiki sections (editable later via the admin panel).
|
||||
// [slug, title, description, sort_order]
|
||||
const WIKI_CATEGORIES = [
|
||||
['guides', 'Guides', 'Getting started and how-to guides.', 10],
|
||||
['world', 'World & Lore', 'Regions, maps, and the story of Mysticmoon.', 20],
|
||||
['world', 'World & Lore', `Regions, maps, and the story of ${brand.shortName}.`, 20],
|
||||
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
|
||||
['community', 'Community & Rules', 'Player conduct and shard policies.', 40],
|
||||
]
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "uomysticmoon-server",
|
||||
"name": "runic-gateway-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "uomysticmoon-server",
|
||||
"name": "runic-gateway-server",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "uomysticmoon-server",
|
||||
"name": "runic-gateway-server",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API for the UOMysticmoon website and admin panel",
|
||||
"description": "REST API for the Runic Gateway website and admin panel",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
|
||||
@@ -10,6 +10,7 @@ require('dotenv').config()
|
||||
const swaggerUi = require('swagger-ui-express')
|
||||
|
||||
const apiRouter = require('./router/api.router')
|
||||
const brand = require('./config/brand')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
@@ -64,8 +65,41 @@ const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const REPO_ROOT = path.join(SERVER_ROOT, '..')
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads')
|
||||
const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist')
|
||||
const BRAND_DIR = process.env.BRAND_DIR || path.join(REPO_ROOT, 'brand')
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
|
||||
// Escape user/brand text for safe interpolation into the HTML shell.
|
||||
const htmlEscape = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
|
||||
// Template the built index.html <head> with instance branding (title, meta
|
||||
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
|
||||
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
|
||||
function renderIndexHtml(html) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
||||
// stored file is never interpreted as anything other than its declared type
|
||||
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
||||
@@ -89,7 +123,7 @@ try {
|
||||
res.json(swaggerSpec)
|
||||
})
|
||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||
customSiteTitle: 'UOMysticmoon API docs',
|
||||
customSiteTitle: `${brand.name} API docs`,
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
}))
|
||||
} catch (err) {
|
||||
@@ -112,15 +146,30 @@ app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||
// ── Client SPA ────────────────────────────────────────────────────────
|
||||
// Serve the built React app if present; otherwise show a placeholder so the
|
||||
// server is usable API-only before the frontend phase.
|
||||
// Brand assets (logo/hero/favicon) from a mounted directory, used when BRAND_*
|
||||
// paths point at /brand/*. Optional — the defaults live under the SPA's /assets,
|
||||
// so this only matters for a custom mount.
|
||||
if (fs.existsSync(BRAND_DIR)) {
|
||||
app.use(
|
||||
'/brand',
|
||||
express.static(BRAND_DIR, {
|
||||
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
||||
app.use(express.static(CLIENT_DIST))
|
||||
app.get('*', (req, res) => res.sendFile(path.join(CLIENT_DIST, 'index.html')))
|
||||
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
||||
// their own cache-friendly static handler.
|
||||
const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
app.use(express.static(CLIENT_DIST, { index: false }))
|
||||
app.get('*', (req, res) => res.type('html').send(indexHtml))
|
||||
} else {
|
||||
app.get('*', (req, res) =>
|
||||
res
|
||||
.type('html')
|
||||
.send(
|
||||
'<h1>UOMysticmoon API</h1><p>The web client has not been built yet. ' +
|
||||
`<h1>${htmlEscape(brand.name)} API</h1><p>The web client has not been built yet. ` +
|
||||
'The API is available under <code>/api/v1</code>.</p>',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ require('dotenv').config()
|
||||
const log = require('../utils/logger')('auth')
|
||||
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
|
||||
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
|
||||
const COOKIE_NAME = process.env.COOKIE_NAME || 'rg_token'
|
||||
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
|
||||
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
|
||||
|
||||
|
||||
46
server/src/config/brand.js
Normal file
46
server/src/config/brand.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// ── Branding (BRAND_*) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Single source of instance branding. Reads BRAND_* env vars once at startup,
|
||||
// with Runic Gateway defaults, so any instance substitutes its own identity
|
||||
// without a rebuild (the app ships as one prebuilt image).
|
||||
//
|
||||
// How it reaches the UI:
|
||||
// • Text + colors + asset paths are surfaced to the SPA through the public
|
||||
// settings API (settings.model.getPublic → SiteContext). The two fields the
|
||||
// admin can edit (site title, contact email) override these defaults.
|
||||
// • The static index.html shell (title/description/OG/favicon) is templated by
|
||||
// Express at serve time (see src/app.js).
|
||||
// • Server-side consumers (emails, TOTP issuer, API docs) read this directly.
|
||||
//
|
||||
// Image assets are delivered from the /brand mount (BRAND_LOGO/HERO/FAVICON), or
|
||||
// any absolute URL. Runic Gateway ships neutral defaults baked into the image so
|
||||
// an instance with no BRAND_* set still renders.
|
||||
require('dotenv').config()
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
|
||||
const brand = {
|
||||
name,
|
||||
shortName: process.env.BRAND_SHORT_NAME || name,
|
||||
tagline: process.env.BRAND_TAGLINE || 'an independent private Ultima Online shard',
|
||||
description:
|
||||
process.env.BRAND_DESCRIPTION ||
|
||||
`${name} — an independent private Ultima Online shard. News, screenshots, guides, and community notes.`,
|
||||
contactEmail: process.env.BRAND_CONTACT_EMAIL || process.env.CONTACT_TO || '',
|
||||
url: process.env.BRAND_URL || '',
|
||||
// Visual
|
||||
accent: process.env.BRAND_ACCENT_COLOR || '#7f99bd',
|
||||
logo: process.env.BRAND_LOGO || '', // empty → no logo image rendered
|
||||
hero: process.env.BRAND_HERO || '/assets/img/runic-emblem.png',
|
||||
favicon: process.env.BRAND_FAVICON || '/assets/img/favicon.ico', // Runic Gateway default emblem
|
||||
}
|
||||
|
||||
// Discord embeds want an int (0xRRGGBB). Parse the accent hex once; fall back to
|
||||
// the default accent if it's malformed.
|
||||
brand.accentInt = (() => {
|
||||
const hex = String(brand.accent).replace('#', '')
|
||||
const n = parseInt(hex, 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
})()
|
||||
|
||||
module.exports = brand
|
||||
47
server/src/model/invites/invites.db.js
Normal file
47
server/src/model/invites/invites.db.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, token_hash, email, role, status, invited_by, accepted_user_id, expires_at, created_at, accepted_at'
|
||||
|
||||
async function insert({ tokenHash, email, role, invitedBy, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO user_invites (token_hash, email, role, invited_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[tokenHash, email, role, invitedBy ?? null, expiresAt],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findByTokenHash(tokenHash) {
|
||||
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE token_hash = ? LIMIT 1`, [tokenHash])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listRecent = (limit) =>
|
||||
query(`SELECT ${COLS} FROM user_invites ORDER BY created_at DESC LIMIT ?`, [limit])
|
||||
|
||||
// Mark accepted only if still pending (atomic guard against a double-accept race).
|
||||
// Returns rows changed (1 = we won, 0 = already used/revoked).
|
||||
async function markAccepted(id, userId) {
|
||||
const res = await query(
|
||||
`UPDATE user_invites SET status = 'accepted', accepted_user_id = ?, accepted_at = NOW()
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[userId, id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
async function revoke(id) {
|
||||
const res = await query(
|
||||
`UPDATE user_invites SET status = 'revoked' WHERE id = ? AND status = 'pending'`,
|
||||
[id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
module.exports = { insert, getById, findByTokenHash, listRecent, markAccepted, revoke }
|
||||
73
server/src/model/invites/invites.model.js
Normal file
73
server/src/model/invites/invites.model.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// Admin email invites. A staff member invites someone by email at a pre-chosen
|
||||
// access level; the invitee accepts via a tokened link that creates their website
|
||||
// user at that role. The opaque token lives only in the emailed link — the DB
|
||||
// stores just its sha256 hash (like mobile refresh tokens), so a DB read never
|
||||
// yields a usable invite. Invites are single-use and expiring.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./invites.db')
|
||||
|
||||
const DEFAULT_TTL_DAYS = 7
|
||||
|
||||
function hashToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Public-safe shape (never exposes the token hash).
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
invitedBy: row.invited_by,
|
||||
acceptedUserId: row.accepted_user_id,
|
||||
expiresAt: row.expires_at,
|
||||
createdAt: row.created_at,
|
||||
acceptedAt: row.accepted_at,
|
||||
expired: new Date(row.expires_at).getTime() < Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create an invite. Returns { invite, token } — the plaintext token is returned
|
||||
// ONCE (for the email link) and never stored or recoverable afterwards.
|
||||
async function create({ email, role, invitedBy, ttlDays = DEFAULT_TTL_DAYS }) {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
|
||||
const id = await db.insert({ tokenHash: hashToken(token), email, role, invitedBy, expiresAt })
|
||||
return { invite: toSafe(await db.getById(id)), token }
|
||||
}
|
||||
|
||||
// Resolve a pending, unexpired invite from its plaintext token, else null. Returns
|
||||
// the RAW row (incl. id) for the accept flow; callers sanitize with publicView.
|
||||
async function findValidByToken(token) {
|
||||
if (!token) return null
|
||||
const row = await db.findByTokenHash(hashToken(token))
|
||||
if (!row || row.status !== 'pending') return null
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return null
|
||||
return row
|
||||
}
|
||||
|
||||
// Atomically consume a pending invite (double-accept-safe). Returns true if this
|
||||
// call won the race and bound the invite to userId.
|
||||
async function accept(id, userId) {
|
||||
return (await db.markAccepted(id, userId)) === 1
|
||||
}
|
||||
|
||||
const revoke = (id) => db.revoke(id)
|
||||
|
||||
async function list(limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await db.listRecent(n)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
// A minimal, safe view of an invite for the (unauthenticated) accept page —
|
||||
// only what the form needs, never the token or internal ids.
|
||||
function publicView(row) {
|
||||
if (!row) return null
|
||||
return { email: row.email, role: row.role }
|
||||
}
|
||||
|
||||
module.exports = { create, findValidByToken, accept, revoke, list, publicView, toSafe, hashToken }
|
||||
@@ -1,4 +1,5 @@
|
||||
const settingsDb = require('./settings.db')
|
||||
const brand = require('../../config/brand')
|
||||
|
||||
// Keys safe to expose on the public site.
|
||||
const PUBLIC_KEYS = [
|
||||
@@ -32,6 +33,27 @@ function registrationFlags(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
|
||||
// disabled — the site never offers game-account creation (link-only).
|
||||
// website — the site is the authority (offer creation; pair with the shard in
|
||||
// website mode + AutoCreateAccounts=false).
|
||||
// hybrid — either side may create (the site offers creation).
|
||||
// game — the game server is the authority; the site does NOT offer creation.
|
||||
// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
|
||||
// (Bridge.cfg) still has the final say and may 403 a call regardless.
|
||||
const GAME_SIGNUP_KEY = 'game_account_signup'
|
||||
const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
|
||||
const GAME_SIGNUP_OFFER = ['website', 'hybrid']
|
||||
|
||||
async function getGameSignupMode() {
|
||||
const v = await settingsDb.get(GAME_SIGNUP_KEY)
|
||||
return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
|
||||
}
|
||||
|
||||
async function isGameAccountSignupEnabled() {
|
||||
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
return settingsDb.get(key)
|
||||
}
|
||||
@@ -64,6 +86,25 @@ async function getPublic() {
|
||||
// page show/hide the password form and SSO buttons.
|
||||
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
|
||||
out.registration = registrationFlags(mode)
|
||||
// Whether the site offers game-account creation (the shard's own mode still has
|
||||
// the final say when the call is made). Lets the portal show/hide the form.
|
||||
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
|
||||
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
||||
// Instance branding (BRAND_* env defaults). The two admin-editable settings —
|
||||
// site title and contact email — override the env value when set, so existing
|
||||
// installs keep their DB-configured name; everything else comes from env.
|
||||
out.brand = {
|
||||
name: out.site_title || brand.name,
|
||||
shortName: brand.shortName,
|
||||
tagline: brand.tagline,
|
||||
description: brand.description,
|
||||
contactEmail: out.contact_email || brand.contactEmail,
|
||||
url: brand.url,
|
||||
accent: brand.accent,
|
||||
logo: brand.logo,
|
||||
hero: brand.hero,
|
||||
favicon: brand.favicon,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -78,4 +119,8 @@ module.exports = {
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
registrationFlags,
|
||||
GAME_SIGNUP_KEY,
|
||||
GAME_SIGNUP_MODES,
|
||||
getGameSignupMode,
|
||||
isGameAccountSignupEnabled,
|
||||
}
|
||||
|
||||
@@ -33,4 +33,10 @@ async function isOwnedBy(account, userId) {
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove }
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
|
||||
@@ -31,4 +31,7 @@ async function getByAccount(account) {
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink }
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
|
||||
@@ -33,6 +33,18 @@ async function countOnline() {
|
||||
const listOnline = () =>
|
||||
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||
|
||||
// Online players on any of the given game accounts (admin: a user's linked
|
||||
// accounts). Empty list short-circuits so we never emit `IN ()`.
|
||||
const listOnlineByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${ONLINE_COLS} FROM shard_online
|
||||
WHERE acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY name ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// Staff roles whose online presence is shown on the public Shard page. Players
|
||||
// who link an account are NOT surfaced publicly — only staff opt into visibility
|
||||
// by virtue of being staff.
|
||||
@@ -89,6 +101,193 @@ async function upsertHouse(serial, fields) {
|
||||
const listIdocHouses = () =>
|
||||
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
||||
|
||||
// Houses owned by any of the given game accounts (admin: a user's linked
|
||||
// accounts). IDOC houses first, then newest-refreshed. Empty list short-circuits.
|
||||
const listHousesByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${HOUSE_REG_COLS} FROM shard_houses
|
||||
WHERE owner_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY is_idoc DESC, updated_at DESC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||
// The registry columns extend HOUSE_COLS; a registry row is one we've seen via
|
||||
// house.update (in_registry = 1), as opposed to a decay-only transition row.
|
||||
const HOUSE_REG_COLS = `${HOUSE_COLS}, owner_name, co_owners, friends, price, decay, in_registry`
|
||||
|
||||
const removeHouse = (serial) => query('DELETE FROM shard_houses WHERE serial = ?', [serial])
|
||||
|
||||
// The full registered-house browser: every row we've seen via house.update.
|
||||
const listRegistryHouses = () =>
|
||||
query(`SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE in_registry = 1 ORDER BY name ASC`)
|
||||
|
||||
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||
const CHAMP_COLS =
|
||||
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
|
||||
|
||||
async function upsertChamp(serial, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['serial', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_champs (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[serial, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
|
||||
const clearChamps = () => query('DELETE FROM shard_champs')
|
||||
// Ordered by name (matches the sidecar's /champs ordering).
|
||||
const listChamps = () => query(`SELECT ${CHAMP_COLS} FROM shard_champs ORDER BY name ASC`)
|
||||
|
||||
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||
const PAGE_COLS =
|
||||
'page_id, type, sender_name, sender_acct, web_id, message, map, x, y, z, sent_ms, handled, handler, payload, updated_at'
|
||||
|
||||
async function upsertPage(pageId, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['page_id', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_pages (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[pageId, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removePage = (pageId) => query('DELETE FROM shard_pages WHERE page_id = ?', [pageId])
|
||||
const clearPages = () => query('DELETE FROM shard_pages')
|
||||
// Oldest-open first so the queue reads like a work list.
|
||||
const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
|
||||
|
||||
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||
const GUILD_COLS =
|
||||
'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at'
|
||||
|
||||
async function upsertGuild(id, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['id', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_guilds (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[id, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
|
||||
const clearGuilds = () => query('DELETE FROM shard_guilds')
|
||||
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
|
||||
|
||||
// The guild an actor LEADS — matched on the current board (leader_serial or the
|
||||
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
|
||||
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||
const findGuildLedByActor = (serial, acct) =>
|
||||
query(
|
||||
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
|
||||
LIMIT 1`,
|
||||
[serial ?? null, acct ?? null],
|
||||
)
|
||||
|
||||
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
|
||||
const listGuildsLedByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY name ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
|
||||
const GOV_COLS =
|
||||
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
|
||||
|
||||
async function upsertGovernor(city, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['city', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_governors (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[city, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`)
|
||||
|
||||
// Cities whose current governor is one of the given game accounts (cross-link:
|
||||
// does this user hold a governorship?). Empty list short-circuits.
|
||||
const listGovernorshipsByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${GOV_COLS} FROM shard_governors
|
||||
WHERE governor_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY city ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// The single open term (ended_at IS NULL) for a city, if any.
|
||||
async function currentGovernorTerm(city) {
|
||||
const rows = await query(
|
||||
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1',
|
||||
[city],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const closeGovernorTerm = (id, endedAt) =>
|
||||
query('UPDATE shard_governor_terms SET ended_at = ? WHERE id = ?', [endedAt, id])
|
||||
|
||||
const openGovernorTerm = ({ city, serial, name, acct, webId, startedAt }) =>
|
||||
query(
|
||||
`INSERT INTO shard_governor_terms
|
||||
(city, governor_serial, governor_name, governor_acct, governor_web_id, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[city, serial ?? null, name ?? null, acct ?? null, webId ?? null, startedAt],
|
||||
)
|
||||
|
||||
const listGovernorTerms = (city, limit) =>
|
||||
query(
|
||||
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? ORDER BY started_at DESC LIMIT ?',
|
||||
[city, limit],
|
||||
)
|
||||
|
||||
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||
async function setPresence({ count, byFacet, byRegion, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_presence (id, count, by_facet, by_region, t) VALUES (1, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE count = VALUES(count), by_facet = VALUES(by_facet),
|
||||
by_region = VALUES(by_region), t = VALUES(t)`,
|
||||
[
|
||||
Number.isFinite(count) ? count : 0,
|
||||
byFacet ? JSON.stringify(byFacet) : null,
|
||||
byRegion ? JSON.stringify(byRegion) : null,
|
||||
Number.isFinite(t) ? t : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async function latestPresence() {
|
||||
const rows = await query('SELECT count, by_facet, by_region, t FROM shard_presence WHERE id = 1')
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -96,9 +295,36 @@ module.exports = {
|
||||
countOnline,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
listOnlineByAccounts,
|
||||
insertEconomy,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdocHouses,
|
||||
listHousesByAccounts,
|
||||
removeHouse,
|
||||
listRegistryHouses,
|
||||
upsertGuild,
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
findGuildLedByActor,
|
||||
listGuildsLedByAccounts,
|
||||
upsertGovernor,
|
||||
listGovernors,
|
||||
listGovernorshipsByAccounts,
|
||||
currentGovernorTerm,
|
||||
closeGovernorTerm,
|
||||
openGovernorTerm,
|
||||
listGovernorTerms,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
listChamps,
|
||||
upsertPage,
|
||||
removePage,
|
||||
clearPages,
|
||||
listPages,
|
||||
}
|
||||
|
||||
@@ -136,9 +136,8 @@ async function upsertHouse(data) {
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
async function listIdoc() {
|
||||
const rows = await db.listIdocHouses()
|
||||
return rows.map((r) => ({
|
||||
function shapeHouse(r) {
|
||||
return {
|
||||
serial: r.serial,
|
||||
stage: r.stage,
|
||||
map: r.map,
|
||||
@@ -149,13 +148,385 @@ async function listIdoc() {
|
||||
name: r.name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerAcct: r.owner_acct,
|
||||
// Registry fields (Protocol 2.0 house.update); undefined on decay-only rows.
|
||||
ownerName: r.owner_name,
|
||||
coOwners: r.co_owners,
|
||||
friends: r.friends,
|
||||
price: r.price == null ? null : Number(r.price),
|
||||
decay: r.decay,
|
||||
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
|
||||
builtOn: r.built_on,
|
||||
lastRefreshed: r.last_refreshed,
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listIdoc() {
|
||||
const rows = await db.listIdocHouses()
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// Houses owned by the given game accounts (admin: a user's linked accounts).
|
||||
async function listHousesForAccounts(accounts) {
|
||||
const rows = await db.listHousesByAccounts(accounts)
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||
// Richer per-house snapshot than the decay-transition feed. Writes only the
|
||||
// registry columns (+ shared location/owner fields); is_idoc/stage stay owned by
|
||||
// the house.decay path, so the two feeds never clobber each other. owner is an
|
||||
// actor object (or null for an abandoned house).
|
||||
async function upsertHouseRegistry(data) {
|
||||
if (!data || !data.serial) return
|
||||
const owner = data.owner || null
|
||||
const fields = {
|
||||
name: data.name ?? null,
|
||||
owner_serial: owner ? owner.serial ?? null : null,
|
||||
owner_acct: owner ? owner.acct ?? null : null,
|
||||
owner_name: owner ? owner.name ?? null : null,
|
||||
co_owners: data.coOwners ?? null,
|
||||
friends: data.friends ?? null,
|
||||
price: data.price ?? null,
|
||||
decay: data.decay ?? null,
|
||||
region: data.region ?? null,
|
||||
map: data.map ?? null,
|
||||
x: data.x ?? null,
|
||||
y: data.y ?? null,
|
||||
z: data.z ?? null,
|
||||
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||
in_registry: 1,
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve())
|
||||
|
||||
async function listHouses() {
|
||||
const rows = await db.listRegistryHouses()
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// Online players on the given game accounts (admin: a user's linked accounts).
|
||||
async function listOnlineForAccounts(accounts) {
|
||||
const rows = await db.listOnlineByAccounts(accounts)
|
||||
return rows.map(shapeOnline)
|
||||
}
|
||||
|
||||
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||
// Upsert a champ spawn's state (champ.update). The full event is stored in
|
||||
// `payload` for the category-specific fields; a few columns are hoisted out for
|
||||
// querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up).
|
||||
async function upsertChamp(ev) {
|
||||
if (!ev || !ev.serial) return
|
||||
await db.upsertChamp(ev.serial, {
|
||||
category: ev.category ?? null,
|
||||
type: ev.type ?? null,
|
||||
name: ev.name ?? null,
|
||||
status: ev.status ?? null,
|
||||
active: ev.active ? 1 : 0,
|
||||
map: ev.map ?? null,
|
||||
x: ev.x ?? null,
|
||||
y: ev.y ?? null,
|
||||
z: ev.z ?? null,
|
||||
boss_up: ev.bossUp ? 1 : 0,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve())
|
||||
const clearChamps = () => db.clearChamps()
|
||||
|
||||
// Return the stored champ.update payload (the shape the sidecar/UI expect),
|
||||
// falling back to the hoisted columns if an older row lacks a payload.
|
||||
function shapeChamp(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'champ.update',
|
||||
serial: r.serial,
|
||||
category: r.category,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
active: Boolean(r.active),
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
bossUp: Boolean(r.boss_up),
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listChamps() {
|
||||
const rows = await db.listChamps()
|
||||
return rows.map(shapeChamp)
|
||||
}
|
||||
|
||||
// Replace the whole board with a fresh snapshot (sidecar GET /champs on connect).
|
||||
async function replaceChamps(spawns) {
|
||||
await db.clearChamps()
|
||||
for (const ev of spawns || []) await upsertChamp(ev)
|
||||
}
|
||||
|
||||
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||
// Upsert a page (page.new / page.updated). The `sender` actor object carries the
|
||||
// name/acct/webId; the rest are top-level fields.
|
||||
async function upsertPage(ev) {
|
||||
const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial))
|
||||
if (!pageId) return
|
||||
const sender = ev.sender || {}
|
||||
await db.upsertPage(pageId, {
|
||||
type: ev.type ?? null,
|
||||
sender_name: sender.name ?? null,
|
||||
sender_acct: sender.acct ?? null,
|
||||
web_id: sender.webId ?? null,
|
||||
message: ev.message ?? null,
|
||||
map: ev.map ?? null,
|
||||
x: ev.x ?? null,
|
||||
y: ev.y ?? null,
|
||||
z: ev.z ?? null,
|
||||
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
|
||||
handled: ev.handled ? 1 : 0,
|
||||
handler: ev.handler ?? null,
|
||||
payload: JSON.stringify(ev),
|
||||
})
|
||||
}
|
||||
|
||||
const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve())
|
||||
const clearPages = () => db.clearPages()
|
||||
|
||||
function shapePage(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return {
|
||||
pageId: r.page_id,
|
||||
type: r.type,
|
||||
sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id },
|
||||
message: r.message,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
sentMs: r.sent_ms == null ? null : Number(r.sent_ms),
|
||||
handled: Boolean(r.handled),
|
||||
handler: r.handler,
|
||||
updatedAt: r.updated_at,
|
||||
// Keep the raw payload available for any field not hoisted above.
|
||||
payload: payload || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function listPages() {
|
||||
const rows = await db.listPages()
|
||||
return rows.map(shapePage)
|
||||
}
|
||||
|
||||
// Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect).
|
||||
async function replacePages(pages) {
|
||||
await db.clearPages()
|
||||
for (const ev of pages || []) await upsertPage(ev)
|
||||
}
|
||||
|
||||
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||
// Upsert a guild's roster snapshot (guild.update). The leader is an actor object
|
||||
// flattened into leader_* columns; the full event lives in `payload`.
|
||||
async function upsertGuild(ev) {
|
||||
if (!ev || ev.id == null) return
|
||||
const leader = ev.leader || {}
|
||||
await db.upsertGuild(ev.id, {
|
||||
name: ev.name ?? null,
|
||||
abbr: ev.abbr ?? null,
|
||||
members: ev.members ?? null,
|
||||
online: ev.online ?? null,
|
||||
alliance: ev.alliance ?? null,
|
||||
leader_serial: leader.serial ?? null,
|
||||
leader_name: leader.name ?? null,
|
||||
leader_acct: leader.acct ?? null,
|
||||
leader_web_id: leader.webId ?? null,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
|
||||
const clearGuilds = () => db.clearGuilds()
|
||||
|
||||
function shapeGuild(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'guild.update',
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
abbr: r.abbr,
|
||||
members: r.members,
|
||||
online: r.online,
|
||||
alliance: r.alliance,
|
||||
leader: r.leader_serial
|
||||
? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
|
||||
: null,
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listGuilds() {
|
||||
const rows = await db.listGuilds()
|
||||
return rows.map(shapeGuild)
|
||||
}
|
||||
|
||||
// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
|
||||
async function replaceGuilds(guilds) {
|
||||
await db.clearGuilds()
|
||||
for (const ev of guilds || []) await upsertGuild(ev)
|
||||
}
|
||||
|
||||
// The guild an actor leads (cross-link on the character sheet). Leadership only —
|
||||
// see the db note; membership for rank-and-file isn't in the feed, so we return
|
||||
// null rather than show a possibly-stale guess.
|
||||
async function findGuildForActor({ serial, acct }) {
|
||||
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
|
||||
const g = rows[0]
|
||||
if (!g) return null
|
||||
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
|
||||
}
|
||||
|
||||
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
|
||||
async function listGuildsLedForAccounts(accounts) {
|
||||
const rows = await db.listGuildsLedByAccounts(accounts)
|
||||
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
|
||||
}
|
||||
|
||||
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
||||
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
||||
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
||||
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
|
||||
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
|
||||
async function upsertGovernor(ev) {
|
||||
if (!ev || !ev.city) return
|
||||
await recordGovernorTransition(ev)
|
||||
const gov = ev.governor || null
|
||||
const elect = ev.governorElect || null
|
||||
await db.upsertGovernor(ev.city, {
|
||||
governor_serial: gov ? gov.serial ?? null : null,
|
||||
governor_name: gov ? gov.name ?? null : null,
|
||||
governor_acct: gov ? gov.acct ?? null : null,
|
||||
governor_web_id: gov ? gov.webId ?? null : null,
|
||||
elect_serial: elect ? elect.serial ?? null : null,
|
||||
elect_name: elect ? elect.name ?? null : null,
|
||||
elect_acct: elect ? elect.acct ?? null : null,
|
||||
election_phase: ev.electionPhase ?? null,
|
||||
candidates: ev.candidates ?? null,
|
||||
auto_pick_at: ev.autoPickAt ? new Date(ev.autoPickAt) : null,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
// Close the open term and open a new one when the governor CHANGES. Idempotent:
|
||||
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
|
||||
// city.update events never spawn spurious terms).
|
||||
async function recordGovernorTransition(ev) {
|
||||
const gov = ev.governor || null
|
||||
const newSerial = gov ? gov.serial ?? null : null
|
||||
const t = Number.isFinite(ev.t) ? ev.t : Date.now()
|
||||
const open = await db.currentGovernorTerm(ev.city)
|
||||
const openSerial = open ? open.governor_serial : null
|
||||
if (open && openSerial === newSerial) return // unchanged — nothing to record
|
||||
if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
|
||||
if (newSerial) {
|
||||
await db.openGovernorTerm({
|
||||
city: ev.city,
|
||||
serial: newSerial,
|
||||
name: gov.name ?? null,
|
||||
acct: gov.acct ?? null,
|
||||
webId: gov.webId ?? null,
|
||||
startedAt: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function shapeGovernor(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'city.update',
|
||||
city: r.city,
|
||||
governor: r.governor_serial
|
||||
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||
: null,
|
||||
governorElect: r.elect_serial
|
||||
? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
|
||||
: null,
|
||||
electionPhase: r.election_phase,
|
||||
candidates: r.candidates,
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listGovernors() {
|
||||
const rows = await db.listGovernors()
|
||||
return rows.map(shapeGovernor)
|
||||
}
|
||||
|
||||
// Cities the given game accounts currently govern (cross-link badge).
|
||||
async function listGovernorshipsForAccounts(accounts) {
|
||||
const rows = await db.listGovernorshipsByAccounts(accounts)
|
||||
return rows.map(shapeGovernor)
|
||||
}
|
||||
|
||||
// Term history for a city (look-back), newest first.
|
||||
async function listGovernorHistory(city, limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await db.listGovernorTerms(city, n)
|
||||
return rows.map((r) => ({
|
||||
city: r.city,
|
||||
governor: r.governor_serial
|
||||
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||
: null,
|
||||
startedAt: r.started_at == null ? null : Number(r.started_at),
|
||||
endedAt: r.ended_at == null ? null : Number(r.ended_at),
|
||||
votes: r.votes,
|
||||
}))
|
||||
}
|
||||
|
||||
// Upsert governors without clearing (cities are fixed, no remove event); term
|
||||
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
|
||||
async function replaceGovernors(cities) {
|
||||
for (const ev of cities || []) await upsertGovernor(ev)
|
||||
}
|
||||
|
||||
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||
async function setPresence(ev) {
|
||||
if (!ev) return
|
||||
await db.setPresence({
|
||||
count: ev.count,
|
||||
byFacet: ev.byFacet || null,
|
||||
byRegion: ev.byRegion || null,
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
async function latestPresence() {
|
||||
const r = await db.latestPresence()
|
||||
if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
|
||||
const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
|
||||
return {
|
||||
count: Number(r.count) || 0,
|
||||
byFacet: parse(r.by_facet),
|
||||
byRegion: parse(r.by_region),
|
||||
t: r.t == null ? null : Number(r.t),
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(s) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
setOffline,
|
||||
@@ -163,9 +534,38 @@ module.exports = {
|
||||
onlineCount,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
listOnlineForAccounts,
|
||||
addEconomySample,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdoc,
|
||||
listHousesForAccounts,
|
||||
upsertHouseRegistry,
|
||||
removeHouse,
|
||||
listHouses,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
listChamps,
|
||||
replaceChamps,
|
||||
upsertPage,
|
||||
removePage,
|
||||
clearPages,
|
||||
listPages,
|
||||
replacePages,
|
||||
upsertGuild,
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
replaceGuilds,
|
||||
findGuildForActor,
|
||||
listGuildsLedForAccounts,
|
||||
upsertGovernor,
|
||||
listGovernors,
|
||||
listGovernorshipsForAccounts,
|
||||
listGovernorHistory,
|
||||
replaceGovernors,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const settings = require('../../../model/settings/settings.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
@@ -22,6 +23,11 @@ const log = require('../../../utils/logger')('admin')
|
||||
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
|
||||
async function announceIfNewlyPublished(post, transition) {
|
||||
await announceJobs.enqueueIfNeeded(post, transition)
|
||||
// Keep the in-game Town Cryer News gump in sync with the same transition: push
|
||||
// the article when it becomes published news, refresh it silently on an edit,
|
||||
// and pull it when it leaves published-news. Best-effort (never throws), so a
|
||||
// sidecar hiccup never breaks saving a post — same guarantee as the enqueue.
|
||||
await newsGump.syncPost(post, transition)
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
@@ -173,8 +179,11 @@ async function publishPost(req, res) {
|
||||
async function deletePost(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const current = await posts.getById(id)
|
||||
await posts.remove(id)
|
||||
await activity.log({ req, action: 'post.delete', detail: { id } })
|
||||
// If it was live in the News gump, pull it (best-effort).
|
||||
if (newsGump.inGump(current)) await newsGump.removePost(id)
|
||||
return res.json({ id })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -485,6 +494,12 @@ async function updateSettings(req, res) {
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid player_registration value' })
|
||||
}
|
||||
if (
|
||||
settings.GAME_SIGNUP_KEY in updates &&
|
||||
!settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid game_account_signup value' })
|
||||
}
|
||||
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
|
||||
// against the same allowlist as post/wiki bodies so a stored value is safe (the
|
||||
// client re-sanitizes on render as defense in depth).
|
||||
|
||||
@@ -12,6 +12,9 @@ const authProviders = require('./authProviders.controller')
|
||||
const discordBot = require('./discordBot.controller')
|
||||
const emailConfig = require('./emailConfig.controller')
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const invites = require('./invites.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
const pagesCtrl = require('./pages.controller')
|
||||
@@ -179,6 +182,137 @@ adminRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
selfShard.getSales,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
|
||||
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
selfShard.createGameAccount,
|
||||
)
|
||||
|
||||
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||||
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||||
// well as admins (modAccess). `actor` is stamped server-side from the session in
|
||||
// the controller — the body never carries it. See shardOps.controller.js.
|
||||
adminRouter.post(
|
||||
'/shard/kick',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Kick every live session of an account (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.kick,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/ban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
|
||||
body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
shardOps.ban,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/unban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Clear an account ban (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
shardOps.unban,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/broadcast',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('text').isString().trim().isLength({ min: 1, max: 300 }),
|
||||
body('hue').optional().isInt({ min: 0, max: 3000 }),
|
||||
validate,
|
||||
shardOps.broadcast,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/pages',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
modAccess,
|
||||
shardOps.listPages,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/pages/:id/respond',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('message').isString().trim().isLength({ min: 1, max: 500 }),
|
||||
body('close').optional().isBoolean(),
|
||||
validate,
|
||||
shardOps.respondPage,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/pages/:id/close',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.closePage,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/audit',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listAudit,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
|
||||
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listHouses,
|
||||
)
|
||||
|
||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||
const UPLOAD_DIR =
|
||||
@@ -1082,6 +1216,139 @@ adminRouter.delete(
|
||||
ctrl.deleteUser,
|
||||
)
|
||||
|
||||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||
// already grants admins a bypass to any account), so no routes for them here.
|
||||
adminRouter.get(
|
||||
'/users/:id',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Get a single user (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getUser,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/users/:id/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
)
|
||||
|
||||
// ── Email invites (admin only) ─────────────────────────────────────────────
|
||||
adminRouter.post(
|
||||
'/invites',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
adminOnly,
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
|
||||
validate,
|
||||
invites.create,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/invites',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'List recent invites (no tokens)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
adminOnly,
|
||||
invites.list,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/invites/:id',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Revoke a pending invite'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
invites.revoke,
|
||||
)
|
||||
|
||||
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||||
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
||||
|
||||
90
server/src/router/v1/admin/invites.controller.js
Normal file
90
server/src/router/v1/admin/invites.controller.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// ── Admin: email invites ───────────────────────────────────────────────────
|
||||
//
|
||||
// Admin-only. A staff member invites someone by email at a pre-chosen access
|
||||
// level; the invitee accepts via a tokened link (auth/invite.controller) which
|
||||
// creates their website user at that role. The plaintext token exists only in the
|
||||
// emailed link and in the create response (so the admin can copy the link if email
|
||||
// isn't configured); the DB stores only its hash.
|
||||
|
||||
const invites = require('../../../model/invites/invites.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-invites')
|
||||
|
||||
const ROLES = ['admin', 'editor', 'moderator', 'player']
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function acceptUrl(token) {
|
||||
return `${baseUrl()}/invite/${token}`
|
||||
}
|
||||
|
||||
// POST /admin/invites — create an invite. Optionally email it (sendEmail, default
|
||||
// true); the copyable accept link is ALWAYS returned so the admin can hand it over
|
||||
// directly. The token is single-use + expiring and the caller is the authenticated
|
||||
// admin who made it, so echoing the link back to them is safe.
|
||||
async function create(req, res) {
|
||||
const email = String(req.body.email || '').trim()
|
||||
const role = req.body.role
|
||||
const sendEmail = req.body.sendEmail !== false // default true
|
||||
if (!email || !ROLES.includes(role)) {
|
||||
return res.status(400).json({ message: 'A valid email and role are required.' })
|
||||
}
|
||||
try {
|
||||
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
|
||||
const url = acceptUrl(token)
|
||||
|
||||
// Send the email only if asked. A send failure doesn't delete the invite — the
|
||||
// link is still returned so the admin can share it manually.
|
||||
let emailed = false
|
||||
let emailError = null
|
||||
if (sendEmail) {
|
||||
try {
|
||||
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
|
||||
emailed = Boolean(result.sent)
|
||||
if (!result.sent && result.reason === 'NOT_CONFIGURED') emailError = 'email is not configured'
|
||||
} catch (err) {
|
||||
emailError = err.message
|
||||
log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
await activity.log({ req, userId: req.user.id, action: 'invite.create', detail: { email, role, emailed } })
|
||||
log.info('invite created', { id: invite.id, email, role, emailed, by: req.user.username })
|
||||
|
||||
// acceptUrl is always returned (copyable link); emailed says whether it also went out.
|
||||
return res.status(201).json({ invite, emailed, acceptUrl: url, emailError })
|
||||
} catch (err) {
|
||||
log.error('create invite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/invites — recent invites (no tokens).
|
||||
async function list(req, res) {
|
||||
try {
|
||||
return res.json(await invites.list(req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('list invites', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/invites/:id — revoke a pending invite.
|
||||
async function revoke(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const changed = await invites.revoke(id)
|
||||
if (!changed) return res.status(404).json({ message: 'No pending invite to revoke.' })
|
||||
await activity.log({ req, userId: req.user.id, action: 'invite.revoke', detail: { id } })
|
||||
return res.json({ id, revoked: true })
|
||||
} catch (err) {
|
||||
log.error('revoke invite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { create, list, revoke }
|
||||
171
server/src/router/v1/admin/shardOps.controller.js
Normal file
171
server/src/router/v1/admin/shardOps.controller.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// ── Admin: in-game staff operations (uo-link write plane + support queue) ────
|
||||
//
|
||||
// The privileged "write plane" (§6 of the sidecar guide): kick / ban / unban /
|
||||
// broadcast against the live shard, plus the help-page (support ticket) queue.
|
||||
// Gated admin+moderator at the route (modAccess) — the sidecar trusts the
|
||||
// loopback socket, so authorization is entirely the site's responsibility.
|
||||
//
|
||||
// SECURITY: `actor` (who is taking the action) is ALWAYS set here from the
|
||||
// authenticated session (req.user.username), never from the request body, so an
|
||||
// action can't be attributed to someone else. The shard records it in its console
|
||||
// log, the ban's BanDealer tag, and the admin.audit event it echoes back.
|
||||
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-shard-ops')
|
||||
|
||||
// Map a never-throw uoLinkClient result onto an HTTP response. `okData` shapes the
|
||||
// success body. Mirrors the sidecar's documented status codes so the UI can tell a
|
||||
// transient outage (503/504 — retry) from a real rejection (403/404).
|
||||
function relay(res, result, okData) {
|
||||
if (result.ok) return res.json(okData(result.data))
|
||||
switch (result.status) {
|
||||
case 400:
|
||||
return res.status(400).json({ message: (result.data && result.data.error) || 'The shard rejected that request.' })
|
||||
case 403:
|
||||
return res.status(403).json({
|
||||
message:
|
||||
(result.data && result.data.error) ||
|
||||
'That action was refused — the target is protected, or the write plane is disabled on the shard.',
|
||||
})
|
||||
case 404:
|
||||
return res.status(404).json({ message: 'No such account or target on the shard.' })
|
||||
case 503:
|
||||
case 504:
|
||||
case 0:
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||
default:
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/kick — disconnect every live session of an account (or serial).
|
||||
async function kick(req, res) {
|
||||
const { account, serial } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminKick({ actor, account, serial })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.kick', detail: { account, serial } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.kick', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/ban — ban an account (works offline); durationSec 0/absent = indefinite.
|
||||
async function ban(req, res) {
|
||||
const { account, serial, durationSec, reason } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminBan({ actor, account, serial, durationSec, reason })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.ban', detail: { account, serial, durationSec, reason } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.ban', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/unban — clear an account's ban.
|
||||
async function unban(req, res) {
|
||||
const { account } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminUnban({ actor, account })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.unban', detail: { account } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.unban', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/broadcast — a system message to everyone online.
|
||||
async function broadcast(req, res) {
|
||||
const { text, hue } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminBroadcast({ actor, text, hue })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.broadcast', detail: { text } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.broadcast', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/shard/pages — the open help-page (support) queue, from our store.
|
||||
async function listPages(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listPages())
|
||||
} catch (err) {
|
||||
log.error('shardOps.listPages', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/pages/:id/respond — reply to a player (optionally close).
|
||||
async function respondPage(req, res) {
|
||||
const { id } = req.params
|
||||
const { message, close } = req.body
|
||||
try {
|
||||
const result = await uoLinkClient.respondPage(id, { message, close: Boolean(close) })
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'shard.page.respond', detail: { pageId: id, close: Boolean(close) } })
|
||||
// Close removes the page from the queue; reflect it locally at once (the
|
||||
// page.closed event will confirm it, but the UI shouldn't wait a poll cycle).
|
||||
if (close) await shardState.removePage(id).catch(() => {})
|
||||
}
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.respondPage', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/pages/:id/close — resolve a page without a reply.
|
||||
async function closePage(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
const result = await uoLinkClient.closePage(id)
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'shard.page.close', detail: { pageId: id } })
|
||||
await shardState.removePage(id).catch(() => {})
|
||||
}
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.closePage', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/shard/audit — recent moderation audit events (admin.audit), from the
|
||||
// ingested event log. Seeds the live audit log the panel keeps current over SSE.
|
||||
async function listAudit(req, res) {
|
||||
try {
|
||||
const limit = req.query.limit
|
||||
return res.json(await shardEvents.list({ kind: 'admin.audit', limit }))
|
||||
} catch (err) {
|
||||
log.error('shardOps.listAudit', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
|
||||
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
|
||||
// houses with location; this is the complete board, kept live for staff on the
|
||||
// admin SSE channel (house.update / house.remove).
|
||||
async function listHouses(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listHouses())
|
||||
} catch (err) {
|
||||
log.error('shardOps.listHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }
|
||||
142
server/src/router/v1/admin/usersShard.controller.js
Normal file
142
server/src/router/v1/admin/usersShard.controller.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// ── Admin: a single user's shard (uo-link) footprint ──────────────────────────
|
||||
//
|
||||
// Backs the /admin/users/:id detail page. Every read is scoped to the target
|
||||
// user's linked game accounts (from the local shard_account_links mirror): their
|
||||
// vendor sales, houses, and currently-online characters. The live character
|
||||
// rosters are fetched separately by the client through the existing admin-bypass
|
||||
// /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are
|
||||
// fast, DB-backed reads. Admin-only (registered under adminOnly in the router).
|
||||
|
||||
const users = require('../../../model/users/users.model')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-user-shard')
|
||||
|
||||
// Resolve the target user's linked game accounts, or null if the user id is
|
||||
// unknown (so the handler can 404 rather than silently returning an empty set).
|
||||
async function accountsForUser(id) {
|
||||
const user = await users.getById(id)
|
||||
if (!user) return null
|
||||
const links = await shardLinks.listForUser(id)
|
||||
return { user, links, accounts: links.map((l) => l.account) }
|
||||
}
|
||||
|
||||
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
||||
async function getUser(req, res) {
|
||||
try {
|
||||
const user = await users.getById(Number(req.params.id))
|
||||
if (!user) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(user)
|
||||
} catch (err) {
|
||||
log.error('getUser', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(ctx.links)
|
||||
} catch (err) {
|
||||
log.error('listAccounts', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts.
|
||||
async function getSales(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await salesForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getSales', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/houses — houses owned by the user's accounts.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await shardState.listHousesForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/online — the user's characters currently online.
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await shardState.listOnlineForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
|
||||
// city governorships they currently hold and guilds they lead. Both are reliable
|
||||
// current-state lookups on the user's linked accounts.
|
||||
async function getStanding(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
const [governorOf, guildsLed] = await Promise.all([
|
||||
shardState.listGovernorshipsForAccounts(ctx.accounts),
|
||||
shardState.listGuildsLedForAccounts(ctx.accounts),
|
||||
])
|
||||
return res.json({ governorOf, guildsLed })
|
||||
} catch (err) {
|
||||
log.error('getStanding', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
|
||||
// user, site-side. `actor` is stamped from the session (never the browser). On
|
||||
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
|
||||
// local mirror so attribution stops immediately.
|
||||
async function unlinkAccount(req, res) {
|
||||
const { account } = req.params
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
|
||||
if (!ctx.accounts.includes(account)) {
|
||||
return res.status(404).json({ message: 'That account is not linked to this user.' })
|
||||
}
|
||||
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
|
||||
if (result.ok) {
|
||||
await shardLinks.removeByAccount(account)
|
||||
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
|
||||
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
|
||||
return res.json({ account, unlinked: true })
|
||||
}
|
||||
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
|
||||
if (result.status === 404) {
|
||||
// Not linked on the shard — reconcile our mirror anyway so the two agree.
|
||||
await shardLinks.removeByAccount(account)
|
||||
return res.status(404).json({ message: 'That account is not linked.' })
|
||||
}
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
|
||||
} catch (err) {
|
||||
log.error('unlinkAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
@@ -188,4 +188,4 @@ async function me(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }
|
||||
module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
const { getInvite, acceptInvite } = require('./invite.controller')
|
||||
const { isLoggedIn } = require('../../../utils/auth')
|
||||
const { attachSession } = require('../../../auth/session.middleware')
|
||||
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
|
||||
@@ -87,6 +88,38 @@ authRouter.post(
|
||||
loginTotp,
|
||||
)
|
||||
|
||||
// ── Email-invite acceptance (public, token-gated) ──────────────────────────
|
||||
authRouter.get(
|
||||
'/invite/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Look up an email invite by token'
|
||||
// #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.'
|
||||
/* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
validate,
|
||||
getInvite,
|
||||
)
|
||||
authRouter.post(
|
||||
'/invite/:token/accept',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
|
||||
// #swagger.description = 'Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
registerLimiter,
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
body(HONEYPOT_FIELD).optional(),
|
||||
validate,
|
||||
acceptInvite,
|
||||
)
|
||||
|
||||
authRouter.post(
|
||||
'/logout',
|
||||
// #swagger.tags = ['Auth']
|
||||
|
||||
82
server/src/router/v1/auth/invite.controller.js
Normal file
82
server/src/router/v1/auth/invite.controller.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// ── Invite acceptance (public, token-gated) ────────────────────────────────
|
||||
//
|
||||
// The other end of the admin email-invite flow (admin/invites.controller). An
|
||||
// invitee opens the tokened link, sees their pre-assigned email + role, and sets
|
||||
// a username + password. Accepting creates their website user AT THE PRESET ROLE
|
||||
// (bypassing the player_registration gate — the invite is its own authority) and
|
||||
// logs them straight in. The optional "create game account" step afterwards reuses
|
||||
// POST /player/shard/account (players only), so it isn't handled here.
|
||||
|
||||
const invites = require('../../../model/invites/invites.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
const { issueSession, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
|
||||
const log = require('../../../utils/logger')('auth-invite')
|
||||
|
||||
// GET /auth/invite/:token — validate an invite and return what the accept form
|
||||
// needs (email + role). 404 for anything not currently acceptable so we never
|
||||
// distinguish "expired" from "revoked" from "never existed".
|
||||
async function getInvite(req, res) {
|
||||
try {
|
||||
const row = await invites.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
|
||||
return res.json(invites.publicView(row))
|
||||
} catch (err) {
|
||||
log.error('getInvite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/invite/:token/accept — create the user at the invite's role and log
|
||||
// them in. Honeypot + validation mirror register; the invite replaces the
|
||||
// registration-mode gate.
|
||||
async function acceptInvite(req, res) {
|
||||
// Honeypot: a filled hidden field means a bot.
|
||||
if (req.body[HONEYPOT_FIELD]) {
|
||||
log.warn('honeypot invite-accept hit', { ip: req.ip })
|
||||
return res.status(400).json({ message: 'Registration failed.' })
|
||||
}
|
||||
try {
|
||||
const row = await invites.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
|
||||
|
||||
const check = usernamePolicy.validateUsername(req.body.username)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await users.createUser({
|
||||
username: check.name,
|
||||
password: req.body.password,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
emailVerified: true, // they proved control of the address by using the link
|
||||
})
|
||||
} catch (err) {
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
return res.status(409).json({ message: 'That username is already taken.' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Consume the invite atomically. If we lost a double-accept race, roll back the
|
||||
// user we just created so a spent invite never yields two accounts.
|
||||
const won = await invites.accept(row.id, user.id)
|
||||
if (!won) {
|
||||
await users.remove(user.id).catch(() => {})
|
||||
return res.status(409).json({ message: 'This invitation has already been used.' })
|
||||
}
|
||||
|
||||
await activity.log({ req, userId: user.id, action: 'invite.accept', detail: { inviteId: row.id, role: row.role } })
|
||||
log.info('invite accepted', { inviteId: row.id, userId: user.id, role: row.role, ip: req.ip })
|
||||
// New accounts never have TOTP yet — log straight in.
|
||||
return issueSession(req, res, user, 'local')
|
||||
} catch (err) {
|
||||
log.error('acceptInvite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getInvite, acceptInvite }
|
||||
@@ -148,6 +148,25 @@ playerRouter.post(
|
||||
validate,
|
||||
shard.link,
|
||||
)
|
||||
playerRouter.post(
|
||||
'/shard/account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
|
||||
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
shard.createGameAccount,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/accounts',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
@@ -203,5 +222,14 @@ playerRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
shard.getSales,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'The caller’s own houses (home status)'
|
||||
// #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getHouses,
|
||||
)
|
||||
|
||||
module.exports = playerRouter
|
||||
|
||||
@@ -9,13 +9,33 @@
|
||||
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('player-shard')
|
||||
|
||||
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||
|
||||
// Decorate a char.profile with cross-links from our own board data: the guild the
|
||||
// character leads and any city governorship on its account. Best-effort — a
|
||||
// failure here never fails the profile (it's a nicety, not the sheet).
|
||||
async function enrichCharProfile(profile) {
|
||||
if (!profile) return profile
|
||||
try {
|
||||
const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct })
|
||||
if (guild) profile.guild = guild
|
||||
if (profile.acct) {
|
||||
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
|
||||
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// POST /player/shard/link — confirm an in-game link code.
|
||||
async function link(req, res) {
|
||||
const { code } = req.body
|
||||
@@ -102,7 +122,7 @@ async function getChar(req, res) {
|
||||
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)
|
||||
return res.json(await enrichCharProfile(result.data))
|
||||
}
|
||||
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
@@ -120,25 +140,80 @@ async function getChar(req, res) {
|
||||
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)
|
||||
const accounts = links.map((l) => l.account)
|
||||
return res.json(await salesForAccounts(accounts))
|
||||
} 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 }
|
||||
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
|
||||
// their linked accounts. A player sees their own decay/IDOC standing; never
|
||||
// anyone else's. Full detail is fine here — it's their property.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const links = await shardLinks.listForUser(req.user.id)
|
||||
const accounts = links.map((l) => l.account)
|
||||
return res.json(await shardState.listHousesForAccounts(accounts))
|
||||
} catch (err) {
|
||||
log.error('player.shard.getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
|
||||
// The password is never echoed anywhere; only the mapped reason is returned.
|
||||
function mapCreateAccountError(res, result) {
|
||||
const reason = (result.data && result.data.reason) || ''
|
||||
switch (result.status) {
|
||||
case 409:
|
||||
return res.status(409).json({ message: 'That account name is already taken.' })
|
||||
case 429:
|
||||
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
|
||||
case 403:
|
||||
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
|
||||
case 400:
|
||||
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
|
||||
case 503:
|
||||
case 0:
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
default:
|
||||
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/shard/account — provision a GAME account for the signed-in website
|
||||
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
|
||||
// invite-accept "create game account" step alike (both act as the signed-in user).
|
||||
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
|
||||
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
|
||||
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
|
||||
async function createGameAccount(req, res) {
|
||||
const { account, password } = req.body
|
||||
try {
|
||||
if (!(await settings.isGameAccountSignupEnabled())) {
|
||||
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
|
||||
}
|
||||
const result = await uoLinkClient.createAccount({
|
||||
actor: req.user.username,
|
||||
account,
|
||||
password,
|
||||
websiteUserId: req.user.id,
|
||||
ip: req.ip,
|
||||
})
|
||||
if (result.ok) {
|
||||
// Mirror the link locally so the portal lists the account immediately.
|
||||
await shardLinks.link({ account, userId: req.user.id })
|
||||
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
|
||||
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
|
||||
return res.status(201).json({ account, linked: true })
|
||||
}
|
||||
return mapCreateAccountError(res, result)
|
||||
} catch (err) {
|
||||
log.error('player.shard.createGameAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }
|
||||
|
||||
@@ -176,6 +176,58 @@ publicRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getIdoc,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/champs',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
||||
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getChamps,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/guilds',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
||||
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGuilds,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/governors',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
||||
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGovernors,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/governors/:city/history',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Governor term history for a city'
|
||||
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
|
||||
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
param('city').isString().isLength({ min: 1, max: 40 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||
validate,
|
||||
shard.getGovernorHistory,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/presence',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
||||
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
shard.getPresence,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
||||
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getHouses,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/stream',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
|
||||
@@ -92,9 +92,102 @@ async function getIdoc(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/champs — the current champion-spawn board (all categories).
|
||||
// Served from our own store; live deltas (champ.update / champ.remove) arrive on
|
||||
// the public SSE stream so the page can update in place.
|
||||
async function getChamps(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listChamps())
|
||||
} catch (err) {
|
||||
log.error('shard.getChamps', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/guilds — the current guild board. Served from our store;
|
||||
// live via guild.update / guild.remove / guild.join on the public SSE stream.
|
||||
async function getGuilds(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGuilds())
|
||||
} catch (err) {
|
||||
log.error('shard.getGuilds', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors — the current town-governor board (empty on shards
|
||||
// without City Loyalty). Live via city.update on the public SSE stream.
|
||||
async function getGovernors(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGovernors())
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernors', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors/:city/history — the term ledger for one city
|
||||
// (look-back: "who were all the governors of Britain?"), newest first.
|
||||
async function getGovernorHistory(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernorHistory', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/presence — the online-population aggregate (count + per-facet
|
||||
// + per-region). Live via presence.online on the public SSE stream.
|
||||
async function getPresence(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.latestPresence())
|
||||
} catch (err) {
|
||||
log.error('shard.getPresence', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only
|
||||
// their location (name + region + map/coords). Owner, price, co-owners and decay
|
||||
// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay
|
||||
// on the public SSE stream. This is the "where are the falling houses" board.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const idoc = await shardState.listIdoc()
|
||||
const publicHouses = idoc.map((h) => ({
|
||||
serial: h.serial,
|
||||
name: h.name,
|
||||
region: h.region,
|
||||
map: h.map,
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
z: h.z,
|
||||
isIdoc: true,
|
||||
}))
|
||||
return res.json(publicHouses)
|
||||
} catch (err) {
|
||||
log.error('shard.getHouses', 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, getOnline, getIdoc, stream }
|
||||
module.exports = {
|
||||
getStatus,
|
||||
getFeed,
|
||||
getEconomy,
|
||||
getOnline,
|
||||
getIdoc,
|
||||
getChamps,
|
||||
getGuilds,
|
||||
getGovernors,
|
||||
getGovernorHistory,
|
||||
getPresence,
|
||||
getHouses,
|
||||
stream,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const settings = require('./model/settings/settings.model')
|
||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||
const brand = require('./config/brand')
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const log = createLogger('server')
|
||||
@@ -27,12 +28,12 @@ const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
|
||||
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
|
||||
|
||||
async function start() {
|
||||
log.info(`starting UOMysticmoon server v${pkg.version}`, {
|
||||
log.info(`starting ${brand.name} server v${pkg.version}`, {
|
||||
node: process.version,
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`,
|
||||
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`,
|
||||
cookieSecure: process.env.COOKIE_SECURE || 'auto',
|
||||
email: 'gmail-oauth2 (configured in admin → settings)',
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ const pool = mariadb.createPool({
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'uomysticmoon',
|
||||
database: process.env.DB_NAME || 'runic_gateway',
|
||||
connectionLimit: 5,
|
||||
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
|
||||
insertIdAsNumber: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ const nodemailer = require('nodemailer')
|
||||
const emailConfig = require('../model/emailConfig/emailConfig.model')
|
||||
const authProviders = require('../model/authProviders/authProviders.model')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const brand = require('../config/brand')
|
||||
const log = require('./logger')('mailer')
|
||||
|
||||
// Ready to send only when enabled, connected (has a refresh token), and we know
|
||||
@@ -76,7 +77,7 @@ async function sendContactMessage({ name, email, message }) {
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: email,
|
||||
subject: `UOMysticmoon contact from ${name || 'a visitor'}`,
|
||||
subject: `${brand.name} contact from ${name || 'a visitor'}`,
|
||||
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
|
||||
@@ -110,7 +111,7 @@ async function sendTest(to) {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to: recipient,
|
||||
subject: 'UOMysticmoon email test',
|
||||
subject: `${brand.name} email test`,
|
||||
text: 'This is a test message confirming Gmail OAuth2 email delivery is working.',
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
|
||||
@@ -122,4 +123,36 @@ async function sendTest(to) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest }
|
||||
/**
|
||||
* Send an account invite. `to` is the invitee's email, `acceptUrl` the tokened
|
||||
* accept link, `role` their assigned access level, `invitedByName` optional. If
|
||||
* email is not configured, returns { sent: false, reason: 'NOT_CONFIGURED' } so
|
||||
* the caller can surface the accept link for the admin to share manually rather
|
||||
* than throwing. Throws only on an actual send failure.
|
||||
*/
|
||||
async function sendInvite({ to, acceptUrl, role, invitedByName }) {
|
||||
const built = await buildTransport()
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
const roleLabel = role && role !== 'player' ? ` as ${role}` : ''
|
||||
const by = invitedByName ? ` by ${invitedByName}` : ''
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
subject: `Your ${brand.name} invitation`,
|
||||
text:
|
||||
`You have been invited${by} to join ${brand.name}${roleLabel}.\n\n` +
|
||||
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
|
||||
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('invite send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite }
|
||||
|
||||
124
server/src/utils/newsGump.js
Normal file
124
server/src/utils/newsGump.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// ── Town Cryer News gump sync (Protocol 2.1) ───────────────────────────────
|
||||
//
|
||||
// Keeps the in-game Town Cryer *News* gump in sync with the site's published
|
||||
// news posts. Distinct from the scrolling town-crier lines (that's a one-shot
|
||||
// announce leg in announceWorker); this is a STATE SYNC — an article stays in the
|
||||
// gump while its post is published news, and is pulled when the post is
|
||||
// unpublished/deleted/re-categorised.
|
||||
//
|
||||
// The website is the source of truth. POST /news is idempotent (re-post replaces),
|
||||
// so a refresh or a reconnect re-assert is safe. Every call is best-effort and
|
||||
// never throws — a sidecar/shard hiccup must never break saving or deleting a
|
||||
// post. Reliability comes from reassertAll() on every WS (re)connect
|
||||
// (uoLinkSocket.backfill), which re-pushes the current published set silently and
|
||||
// closes the gap if an earlier live push failed.
|
||||
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const { deriveExcerpt } = require('./sanitizeHtml')
|
||||
const log = require('./logger')('news-gump')
|
||||
|
||||
const MAX_TITLE = 120
|
||||
const MAX_BODY = 900
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function clamp(value, max) {
|
||||
const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
||||
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
// A post belongs in the gump exactly when it is published AND in the news category.
|
||||
function inGump(post) {
|
||||
return Boolean(post && post.published && post.category === 'news')
|
||||
}
|
||||
|
||||
// Optional UO gump image id for news articles (a shard art id), from the
|
||||
// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll.
|
||||
async function gumpImage() {
|
||||
try {
|
||||
const raw = await settings.get('news_gump_image')
|
||||
const n = Number(raw)
|
||||
return Number.isInteger(n) && n > 0 ? n : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Build the in-game News article from a post. Body is a compact gump-HTML block
|
||||
// (title centred + a plain-text excerpt) rather than the post's full rich HTML —
|
||||
// the UO gump only supports a small HTML subset, so we keep it predictable. The
|
||||
// "more info" URL is the public news list (news posts have no per-post route).
|
||||
async function buildArticle(post, { announce = true } = {}) {
|
||||
const title = clamp(post.title, MAX_TITLE)
|
||||
const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY)
|
||||
const body = excerpt ? `<CENTER>${title}</CENTER><BR><BR>${excerpt}` : `<CENTER>${title}</CENTER>`
|
||||
return {
|
||||
id: String(post.id),
|
||||
title,
|
||||
body,
|
||||
image: await gumpImage(),
|
||||
url: `${baseUrl()}/site/news`,
|
||||
announce,
|
||||
}
|
||||
}
|
||||
|
||||
// Push a post to the gump (only if it belongs there). announce=true has the criers
|
||||
// proclaim the title; false is a silent refresh/re-assert.
|
||||
async function pushPost(post, { announce = true } = {}) {
|
||||
if (!inGump(post)) return { ok: false, skipped: true }
|
||||
const res = await uoLinkClient.postNews(await buildArticle(post, { announce }))
|
||||
if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error })
|
||||
return res
|
||||
}
|
||||
|
||||
// Remove a post from the gump. A 404 (not present) is not an error worth noting.
|
||||
async function removePost(id) {
|
||||
const res = await uoLinkClient.deleteNews(String(id))
|
||||
if (!res.ok && res.status !== 404) {
|
||||
log.warn('news gump remove failed', { id, status: res.status, error: res.error })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Reconcile the gump after a post create/update/publish. `transition`
|
||||
// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place
|
||||
// edit (silent refresh) and catches a post leaving published-news (pull it).
|
||||
async function syncPost(post, transition = {}) {
|
||||
try {
|
||||
if (inGump(post)) {
|
||||
const wasInGump = Boolean(transition.wasPublished && transition.wasNews)
|
||||
await pushPost(post, { announce: !wasInGump })
|
||||
} else if (transition.wasPublished && transition.wasNews) {
|
||||
await removePost(post.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('news gump sync failed', { id: post && post.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Re-push every currently-published news post, silently — run on each WS
|
||||
// (re)connect to reconcile the gump to our source of truth (also recovers any
|
||||
// article whose original live push failed). Best-effort; never throws.
|
||||
async function reassertAll() {
|
||||
try {
|
||||
const list = await posts.listAll('news')
|
||||
const published = (list || []).filter((p) => p.published)
|
||||
let pushed = 0
|
||||
for (const p of published) {
|
||||
const full = await posts.getById(p.id) // list projection may omit the body
|
||||
if (full) {
|
||||
await pushPost(full, { announce: false })
|
||||
pushed += 1
|
||||
}
|
||||
}
|
||||
if (pushed) log.info('re-asserted news gump articles', { count: pushed })
|
||||
} catch (err) {
|
||||
log.warn('news gump reassert failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll }
|
||||
@@ -33,6 +33,20 @@ const PUBLIC_KINDS = new Set([
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
// Champion-spawn board deltas — the public Champions page renders these live.
|
||||
'champ.update',
|
||||
'champ.remove',
|
||||
// Protocol 2.0 boards — public, rendered live on their respective pages.
|
||||
'guild.update',
|
||||
'guild.remove',
|
||||
'guild.join',
|
||||
'city.update',
|
||||
'presence.online',
|
||||
'region.enter',
|
||||
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
|
||||
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
|
||||
// house.decay, which is public above) with location only; the full registry is
|
||||
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
|
||||
])
|
||||
|
||||
// Open response streams per channel.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
||||
const shardStateModel = require('../model/shardState/shardState.model')
|
||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const defaultLog = require('./logger')('shard-ingest')
|
||||
@@ -33,11 +34,17 @@ const LOGGED_KINDS = new Set([
|
||||
'karma.change',
|
||||
'audit.set',
|
||||
'audit.command',
|
||||
'admin.audit',
|
||||
'cheat.fastwalk',
|
||||
'link.request',
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
|
||||
'guild.join',
|
||||
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
|
||||
'account.audit',
|
||||
'account.unlinked',
|
||||
])
|
||||
|
||||
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
|
||||
@@ -132,6 +139,45 @@ async function applyStateChange(event, deps) {
|
||||
lastRefreshed: event.lastRefreshed,
|
||||
})
|
||||
return
|
||||
case 'champ.update':
|
||||
await shardState.upsertChamp(event)
|
||||
return
|
||||
case 'champ.remove':
|
||||
await shardState.removeChamp(event.serial)
|
||||
return
|
||||
case 'page.new':
|
||||
case 'page.updated':
|
||||
await shardState.upsertPage(event)
|
||||
return
|
||||
case 'page.closed':
|
||||
await shardState.removePage(event.pageId)
|
||||
return
|
||||
// ── Protocol 2.0 boards ──────────────────────────────────────────────
|
||||
case 'guild.update':
|
||||
await shardState.upsertGuild(event)
|
||||
return
|
||||
case 'guild.remove':
|
||||
await shardState.removeGuild(event.id)
|
||||
return
|
||||
case 'city.update':
|
||||
// Upserts the board AND captures term history (idempotent).
|
||||
await shardState.upsertGovernor(event)
|
||||
return
|
||||
case 'presence.online':
|
||||
await shardState.setPresence(event)
|
||||
return
|
||||
case 'house.update':
|
||||
await shardState.upsertHouseRegistry(event)
|
||||
return
|
||||
case 'house.remove':
|
||||
await shardState.removeHouse(event.serial)
|
||||
return
|
||||
case 'account.unlinked':
|
||||
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
|
||||
// our local link mirror so attribution stops immediately.
|
||||
if (event.account) await deps.shardLinks.removeByAccount(event.account)
|
||||
return
|
||||
// guild.join / account.audit → logged; region.enter → broadcast-only.
|
||||
default:
|
||||
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
||||
// broadcasting still happen in ingest().
|
||||
@@ -145,6 +191,7 @@ async function ingest(event, deps = {}) {
|
||||
const d = {
|
||||
shardEvents: deps.shardEvents || shardEventsModel,
|
||||
shardState: deps.shardState || shardStateModel,
|
||||
shardLinks: deps.shardLinks || shardLinksModel,
|
||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
log: deps.log || defaultLog,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user