Compare commits
34 Commits
5da27879e5
...
ci/gitea-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f24959d49 | |||
| 99649727f3 | |||
| ac858875c0 | |||
| 986a8d5d86 | |||
| 350433635b | |||
| a2590812e0 | |||
| 5ccb18e794 | |||
| bf9edde5b7 | |||
| 6c310629c7 | |||
| d72c2dadfc | |||
| c4245e3f6a | |||
| 49d0c1bd11 | |||
| 74d2ead958 | |||
| 49ce230c3a | |||
| fe6f93481b | |||
| e7bc316863 | |||
| 1c9a9d26e1 | |||
| 064f02c4b6 | |||
| 523113f013 | |||
| 9d9f5aac28 | |||
| ab647756f0 | |||
| d49008e9f2 | |||
| e7f5f24809 | |||
| 1dd7603f54 | |||
| 4d87c5f627 | |||
| 764fb0c069 | |||
| 6d31869ba2 | |||
| fcef08e9b6 | |||
| 6180e8a071 | |||
| d7fc2dccb7 | |||
| 455e850b91 | |||
| f8652c2399 | |||
| 5b3ab7f282 | |||
| 17d42cebfe |
23
.env.example
23
.env.example
@@ -53,13 +53,10 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the
|
||||
# client to fall back to a mailto: link instead.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
CONTACT_TO=UOMysticmoon@gmail.com
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
||||
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. Until it's connected, the contact form falls back to
|
||||
# a mailto: link (recipient = the `contact_email` site setting).
|
||||
|
||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
@@ -79,3 +76,15 @@ CLIENT_ORIGIN=http://localhost:5173
|
||||
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
||||
BOT_INTERNAL_URL=http://bot:4100
|
||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||
|
||||
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
||||
# website ingests its live event feed and proxies its read queries/commands
|
||||
# (shard status, online players, player-vendor sales, IDOC houses, character
|
||||
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
||||
# a DIFFERENT host from the website, so both URLs are configurable. The
|
||||
# shared-secret auth token is NOT an env var — it is entered in the admin panel
|
||||
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
|
||||
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
||||
UOLINK_PROTOCOL=1
|
||||
|
||||
87
.gitea/workflows/build-images.yml
Normal file
87
.gitea/workflows/build-images.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# • 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)
|
||||
# See the PR description / README for step-by-step token creation.
|
||||
#
|
||||
# Produces, in gitea.whitlocktech.com/<owner>/ :
|
||||
# website-app:latest + website-app:sha-<7>
|
||||
# website-bot:latest + website-bot:sha-<7>
|
||||
|
||||
name: Build container images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: images-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.whitlocktech.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the merged commit
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image refs (registry owner must be lowercase for Docker)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
echo "APP_IMAGE=${REGISTRY}/${OWNER}/website-app" >> "$GITHUB_ENV"
|
||||
echo "BOT_IMAGE=${REGISTRY}/${OWNER}/website-bot" >> "$GITHUB_ENV"
|
||||
echo "TAG=sha-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify the Docker daemon is reachable
|
||||
# Fails fast with a clear message if the host socket isn't mounted into
|
||||
# the job container (the one hard runner prerequisite).
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "::error::Docker daemon not reachable. Mount /var/run/docker.sock into the runner's job containers."
|
||||
exit 1
|
||||
fi
|
||||
echo "Docker daemon OK"
|
||||
|
||||
- name: Log in to the Gitea container registry
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build & push the app image (server + client)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -f Dockerfile \
|
||||
-t "${APP_IMAGE}:latest" \
|
||||
-t "${APP_IMAGE}:${TAG}" \
|
||||
.
|
||||
docker push "${APP_IMAGE}:latest"
|
||||
docker push "${APP_IMAGE}:${TAG}"
|
||||
|
||||
- name: Build & push the bot image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -f bot/Dockerfile \
|
||||
-t "${BOT_IMAGE}:latest" \
|
||||
-t "${BOT_IMAGE}:${TAG}" \
|
||||
.
|
||||
docker push "${BOT_IMAGE}:latest"
|
||||
docker push "${BOT_IMAGE}:${TAG}"
|
||||
|
||||
- name: Log out (clear cached credentials from the runner)
|
||||
if: always()
|
||||
run: docker logout "${REGISTRY}" || true
|
||||
@@ -234,10 +234,13 @@ who"; `activity_log` provides the history feed.
|
||||
|
||||
## 7. Email
|
||||
|
||||
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
|
||||
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
|
||||
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
|
||||
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,11 +290,7 @@ COOKIE_SECURE=true
|
||||
COOKIE_NAME=uomm_token
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
CONTACT_TO=UOMysticmoon@gmail.com
|
||||
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
|
||||
85
README.md
85
README.md
@@ -6,6 +6,7 @@ shard — 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).
|
||||
|
||||
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||
|
||||
@@ -24,6 +25,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
- [Pages & routes](#pages--routes)
|
||||
- [API endpoints](#api-endpoints)
|
||||
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Security](#security)
|
||||
- [Logging](#logging)
|
||||
@@ -39,7 +41,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
|
||||
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||
|
||||
@@ -207,6 +209,9 @@ npm start # node server → serves API + SPA at http://localhost:3
|
||||
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
||||
| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
|
||||
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
|
||||
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
|
||||
|
||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||
@@ -251,6 +256,74 @@ not crash).
|
||||
|
||||
---
|
||||
|
||||
## Shard integration (uo-link)
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
### How it works
|
||||
|
||||
```
|
||||
ServUO shard ──▶ uo-link sidecar (UOM/link) ──▶ website backend ──▶ browser
|
||||
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
||||
```
|
||||
|
||||
- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
|
||||
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
|
||||
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
|
||||
the API — it is never returned to any client and never sent to the browser. Every call the backend
|
||||
makes carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header (a protocol
|
||||
mismatch fails fast with `409` instead of being mis-parsed).
|
||||
- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
|
||||
and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
|
||||
`vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
|
||||
`link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
|
||||
each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
|
||||
kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
|
||||
ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
|
||||
restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
|
||||
the sidecar's `/history`.
|
||||
- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
|
||||
`/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
|
||||
commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
|
||||
throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
|
||||
degrades to a `503`/retry banner instead of a 500.
|
||||
- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
|
||||
Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
|
||||
**admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
|
||||
attempts, IPs). Sensitive kinds can never leak onto the public channel.
|
||||
|
||||
### Account linking
|
||||
|
||||
A player (or staff member) proves ownership of a game account without sharing any game credentials:
|
||||
|
||||
1. In game, the player runs **`[link`** and receives a one-time code.
|
||||
2. On the website (Player portal, or Admin → Account for staff) they enter the code.
|
||||
3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the
|
||||
game account with the website user id, and mirrors the link locally in `shard_account_links`.
|
||||
|
||||
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
|
||||
are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
|
||||
character**; players and editor/moderator staff are limited to their own linked accounts.
|
||||
|
||||
### What each audience sees
|
||||
|
||||
| Surface | Endpoints | Who | Data |
|
||||
|---|---|---|---|
|
||||
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
||||
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
||||
|
||||
The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the
|
||||
shard is offline), every shard surface degrades gracefully — the public page still renders, showing
|
||||
the shard as offline.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.**
|
||||
@@ -276,11 +349,12 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `TOTP_ISSUER` | `UOMysticmoon` | 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) |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
|
||||
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
|
||||
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -343,8 +417,9 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
|
||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||
behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. SMTP is optional — the contact
|
||||
form falls back to a `mailto:` link when unconfigured.
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
||||
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
|
||||
contact form falls back to a `mailto:` link when unconfigured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"diff": "^5.2.2",
|
||||
@@ -1483,6 +1484,19 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
|
||||
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"diff": "^5.2.2",
|
||||
|
||||
@@ -16,20 +16,28 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
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 Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
|
||||
// Admin
|
||||
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
||||
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
||||
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
||||
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
|
||||
import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
||||
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
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 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 AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
@@ -39,6 +47,9 @@ 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 PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
|
||||
export default function App() {
|
||||
@@ -63,10 +74,19 @@ export default function App() {
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
above (React Router ranks static routes over this dynamic one). */}
|
||||
<Route path="/:slug" element={<CmsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
||||
preview link works regardless of site mode. */}
|
||||
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route
|
||||
@@ -79,6 +99,9 @@ export default function App() {
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostsAdmin />} />
|
||||
<Route path="pages" element={<PagesAdmin />} />
|
||||
<Route path="pages/new" element={<PageBuilder />} />
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
@@ -96,6 +119,9 @@ export default function App() {
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
@@ -106,13 +132,16 @@ export default function App() {
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerAccount />
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -73,8 +73,34 @@ export const api = {
|
||||
wikiCategories: () => req('/public/wiki/categories'),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
// is fetched by id + token.
|
||||
page: (slug) => req(`/public/pages/${slug}`),
|
||||
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||
|
||||
// ----- shard live data (uo-link) -----
|
||||
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
||||
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
||||
shard: {
|
||||
status: () => req('/public/shard/status'),
|
||||
feed: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.kind) qs.set('kind', opts.kind)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
const s = qs.toString()
|
||||
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
||||
},
|
||||
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
},
|
||||
// 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
|
||||
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
||||
shardStreamUrl: `${BASE}/public/shard/stream`,
|
||||
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
||||
|
||||
// ----- admin -----
|
||||
admin: {
|
||||
dashboard: () => req('/admin/dashboard'),
|
||||
@@ -86,6 +112,10 @@ export const api = {
|
||||
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
publishPost: (id, published) =>
|
||||
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
||||
// News announcement pipeline (town crier + Discord) status + per-leg retry.
|
||||
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
|
||||
retryAnnounceLeg: (id, leg) =>
|
||||
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
|
||||
uploadImage: (file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
@@ -97,6 +127,15 @@ export const api = {
|
||||
fd.append('image', file)
|
||||
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
// ----- CMS pages (block-based page builder) -----
|
||||
listPages: () => req('/admin/pages'),
|
||||
getPage: (id) => req(`/admin/pages/${id}`),
|
||||
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
|
||||
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
|
||||
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
|
||||
unprotectPage: (id, password) =>
|
||||
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
|
||||
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
|
||||
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
||||
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
||||
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
||||
@@ -180,6 +219,16 @@ export const api = {
|
||||
linkedIdentities: () => req('/admin/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- game account linking (self-service, staff) -----
|
||||
shard: {
|
||||
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/admin/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/shard/sales'),
|
||||
},
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
@@ -189,6 +238,19 @@ export const api = {
|
||||
// ----- Discord bot control (admin only) -----
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||
|
||||
// ----- uo-link sidecar control (admin only) -----
|
||||
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||
getEmailConfig: () => req('/admin/email/config'),
|
||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||
emailConnectUrl: () => req('/admin/email/connect/start'),
|
||||
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
||||
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// ----- player self-service (role: 'player') -----
|
||||
@@ -205,6 +267,16 @@ export const api = {
|
||||
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
linkedIdentities: () => req('/player/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- game account linking (uo-link) -----
|
||||
shard: {
|
||||
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/player/shard/accounts'),
|
||||
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/player/shard/sales'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
26
client/src/blocks/BlockRenderer.jsx
Normal file
26
client/src/blocks/BlockRenderer.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// Renders stored blocks via their registry component. Used by the public page
|
||||
// route, the draft preview, and (recursively) the two_column block. Kept
|
||||
// separate from the registry so both the renderer and the builder can import it.
|
||||
// Import the lookup from the registry directly (not ./index) to avoid a cycle:
|
||||
// index → types/twoColumn → BlockRenderer. The page route/builder import ./index,
|
||||
// which registers every block before anything renders.
|
||||
import { getBlock } from './registry.js'
|
||||
|
||||
/**
|
||||
* Render one block. A block with `visible === false` renders nothing (admins
|
||||
* hide blocks without deleting them). An unknown type also renders nothing —
|
||||
* server validation prevents storing one, so this only guards a client/server
|
||||
* registry skew rather than crashing the whole page.
|
||||
*/
|
||||
export default function BlockRenderer({ block }) {
|
||||
if (!block || block.visible === false) return null
|
||||
const def = getBlock(block.type)
|
||||
if (!def || !def.component) return null
|
||||
const Component = def.component
|
||||
return <Component props={block.props || {}} block={block} />
|
||||
}
|
||||
|
||||
/** Render an ordered array of blocks (array position = display order). */
|
||||
export function BlockList({ blocks }) {
|
||||
return (blocks || []).map((block) => <BlockRenderer key={block.id} block={block} />)
|
||||
}
|
||||
64
client/src/blocks/editorKit.jsx
Normal file
64
client/src/blocks/editorKit.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// Shared form controls for block editors, styled with the existing admin design
|
||||
// system (.field-label / .input / .select). Every block's editor is a
|
||||
// ({ props, onChange }) component; these keep the seven of them consistent and
|
||||
// short. onChange always receives the full next props object.
|
||||
|
||||
export function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">{label}</span>
|
||||
{children}
|
||||
{hint && (
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextField({ label, hint, value, onChange, placeholder, maxLength }) {
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) {
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={rows}
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ resize: 'vertical', fontFamily: 'inherit' }}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
// options: array of [value, label] tuples.
|
||||
export function SelectField({ label, hint, value, onChange, options }) {
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<select className="select" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
19
client/src/blocks/index.js
Normal file
19
client/src/blocks/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Client block registry entrypoint. Importing this module registers every
|
||||
// browser-side block definition (renderer + editor + palette entry) exactly
|
||||
// once, then re-exports the registry API. The page builder and the public page
|
||||
// renderer should import from HERE, not ./registry, so the definitions are
|
||||
// loaded before anything reads the registry.
|
||||
//
|
||||
// Wave 1 definitions are registered below as each block is built (spec build
|
||||
// order step 3), one import per block.
|
||||
|
||||
export * from './registry'
|
||||
|
||||
// ── Wave 1 block definitions (self-register on import) ─────────────────
|
||||
import './types/heading.jsx'
|
||||
import './types/richText.jsx'
|
||||
import './types/image.jsx'
|
||||
import './types/twoColumn.jsx'
|
||||
import './types/cta.jsx'
|
||||
import './types/divider.jsx'
|
||||
import './types/quote.jsx'
|
||||
84
client/src/blocks/registry.js
Normal file
84
client/src/blocks/registry.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Block registry (client side) — mirrors the server registry
|
||||
// (server/src/blocks/registry.js) but carries the browser-only concerns: the
|
||||
// React renderer, the admin edit form, and the palette icon/label. The page
|
||||
// builder's palette, drag-reorder canvas, per-block edit panel, and the public
|
||||
// page renderer all read from this registry, so adding a block later is one
|
||||
// entry here (plus its server-side schema entry) rather than edits scattered
|
||||
// across the builder and renderer.
|
||||
//
|
||||
// A registered definition looks like:
|
||||
// {
|
||||
// type: 'heading', // must match the server registry type
|
||||
// version: 1, // must match the server schema version
|
||||
// label: 'Heading', // palette display name
|
||||
// icon: 'heading', // palette icon key
|
||||
// component: HeadingBlock, // renderer: (props) => JSX
|
||||
// editor: HeadingEditor, // admin edit form: ({ props, onChange }) => JSX
|
||||
// defaults: () => ({ ... }), // starting props when a block is added
|
||||
// container: false, // true only for two_column
|
||||
// containerSlots: [], // ['left','right'] for two_column
|
||||
// }
|
||||
//
|
||||
// This module only defines the pattern; Wave 1 definitions register via
|
||||
// ./index.js as each block is built (spec build order step 3).
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// Kept in sync with the server's RESERVED_KEYS — the only top-level keys on a
|
||||
// stored block object. Exported so the builder can construct envelopes without
|
||||
// hard-coding the shape.
|
||||
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
|
||||
|
||||
/**
|
||||
* Register a block definition. Throws on a duplicate type — a programmer error
|
||||
* caught at module load, not runtime.
|
||||
* @param {object} def
|
||||
* @returns {object} the stored definition
|
||||
*/
|
||||
export function registerBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
|
||||
throw new Error('registerBlock: a block definition needs a string `type`')
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
const entry = {
|
||||
type: def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
label: def.label || def.type,
|
||||
icon: def.icon || null,
|
||||
component: def.component || null,
|
||||
editor: def.editor || null,
|
||||
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
|
||||
container: Boolean(def.container),
|
||||
containerSlots: def.containerSlots ? [...def.containerSlots] : [],
|
||||
}
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
export function getBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `type` is a registered block. */
|
||||
export function hasBlock(type) {
|
||||
return registry.has(type)
|
||||
}
|
||||
|
||||
/** @returns {object[]} all registered definitions (registration order). */
|
||||
export function listBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable block id. Called once when a block is added to the canvas;
|
||||
* never derived from array position, so a reorder keeps ids intact (they are the
|
||||
* React key and the future revision-history join point).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function makeBlockId() {
|
||||
const rand = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||
return `b_${rand}`
|
||||
}
|
||||
63
client/src/blocks/types/cta.jsx
Normal file
63
client/src/blocks/types/cta.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// cta block — a call-to-action button/link. Renders as an anchor styled with the
|
||||
// existing button system (primary / secondary).
|
||||
import { registerBlock } from '../registry'
|
||||
import { SelectField, TextField } from '../editorKit.jsx'
|
||||
|
||||
const STYLES = [
|
||||
['primary', 'Primary'],
|
||||
['secondary', 'Secondary'],
|
||||
]
|
||||
|
||||
function CtaBlock({ props }) {
|
||||
if (!props.url || !props.text) return null
|
||||
const style = props.style === 'secondary' ? 'secondary' : 'primary'
|
||||
// External links get a safe rel; same-origin relative links don't need it.
|
||||
const external = /^https?:\/\//i.test(props.url)
|
||||
return (
|
||||
<div className="page-cta-wrap">
|
||||
<a
|
||||
className={`btn btn-sq page-cta page-cta--${style}`}
|
||||
href={props.url}
|
||||
{...(external ? { rel: 'noopener noreferrer nofollow' } : {})}
|
||||
>
|
||||
{props.text}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CtaEditor({ props, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<TextField
|
||||
label="Button text"
|
||||
value={props.text}
|
||||
maxLength={100}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
<TextField
|
||||
label="URL"
|
||||
hint="A full https:// link or a same-site path like /wiki/getting-started."
|
||||
value={props.url}
|
||||
placeholder="https://…"
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Style"
|
||||
value={props.style || 'primary'}
|
||||
onChange={(style) => onChange({ ...props, style })}
|
||||
options={STYLES}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'cta',
|
||||
version: 1,
|
||||
label: 'Button',
|
||||
icon: '⇥',
|
||||
component: CtaBlock,
|
||||
editor: CtaEditor,
|
||||
defaults: () => ({ text: '', url: '', style: 'primary' }),
|
||||
})
|
||||
25
client/src/blocks/types/divider.jsx
Normal file
25
client/src/blocks/types/divider.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// divider block — a pure spacer / horizontal rule. No props, so its editor is
|
||||
// just a note.
|
||||
import { registerBlock } from '../registry'
|
||||
|
||||
function DividerBlock() {
|
||||
return <hr className="page-divider" />
|
||||
}
|
||||
|
||||
function DividerEditor() {
|
||||
return (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
A divider has no options — it adds a horizontal rule and spacing.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'divider',
|
||||
version: 1,
|
||||
label: 'Divider',
|
||||
icon: '—',
|
||||
component: DividerBlock,
|
||||
editor: DividerEditor,
|
||||
defaults: () => ({}),
|
||||
})
|
||||
46
client/src/blocks/types/heading.jsx
Normal file
46
client/src/blocks/types/heading.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// heading block — plain-text section heading (h1–h4). Text is rendered as text
|
||||
// (React escapes it); use rich_text for inline markup.
|
||||
import { registerBlock } from '../registry'
|
||||
import { SelectField, TextField } from '../editorKit.jsx'
|
||||
|
||||
const LEVELS = [
|
||||
['h1', 'Heading 1'],
|
||||
['h2', 'Heading 2'],
|
||||
['h3', 'Heading 3'],
|
||||
['h4', 'Heading 4'],
|
||||
]
|
||||
const VALID = ['h1', 'h2', 'h3', 'h4']
|
||||
|
||||
function HeadingBlock({ props }) {
|
||||
const Tag = VALID.includes(props.level) ? props.level : 'h2'
|
||||
return <Tag className="page-heading">{props.text}</Tag>
|
||||
}
|
||||
|
||||
function HeadingEditor({ props, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SelectField
|
||||
label="Level"
|
||||
value={props.level || 'h2'}
|
||||
onChange={(level) => onChange({ ...props, level })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
<TextField
|
||||
label="Text"
|
||||
value={props.text}
|
||||
maxLength={200}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'heading',
|
||||
version: 1,
|
||||
label: 'Heading',
|
||||
icon: 'H',
|
||||
component: HeadingBlock,
|
||||
editor: HeadingEditor,
|
||||
defaults: () => ({ level: 'h2', text: '' }),
|
||||
})
|
||||
100
client/src/blocks/types/image.jsx
Normal file
100
client/src/blocks/types/image.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// image block — a single image with optional caption and alignment. Upload
|
||||
// reuses the shared admin uploader (returns { url }); the block stays URL-based
|
||||
// until the Wave 3 asset picker lands.
|
||||
import { useState } from 'react'
|
||||
import { registerBlock } from '../registry'
|
||||
import { api } from '../../api/client.js'
|
||||
import { SelectField, TextField } from '../editorKit.jsx'
|
||||
|
||||
const ALIGN = [
|
||||
['left', 'Left'],
|
||||
['center', 'Center'],
|
||||
['right', 'Right'],
|
||||
['full', 'Full width'],
|
||||
]
|
||||
const VALID = ['left', 'center', 'right', 'full']
|
||||
|
||||
function ImageBlock({ props }) {
|
||||
if (!props.src) return null
|
||||
const align = VALID.includes(props.alignment) ? props.alignment : 'center'
|
||||
return (
|
||||
<figure className={`page-image page-image--${align}`}>
|
||||
<img src={props.src} alt={props.alt || ''} />
|
||||
{props.caption && <figcaption>{props.caption}</figcaption>}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageEditor({ props, onChange }) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function onUpload(e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { url } = await api.admin.upload(file)
|
||||
onChange({ ...props, src: url })
|
||||
} catch (err) {
|
||||
setError(err.message || 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<span className="field-label">Image</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={onUpload}
|
||||
className="sans"
|
||||
style={{ color: 'var(--muted)', fontSize: '0.85rem', display: 'block' }}
|
||||
/>
|
||||
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading…</span>}
|
||||
{error && <span className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{error}</span>}
|
||||
{props.src && (
|
||||
<img
|
||||
src={props.src}
|
||||
alt=""
|
||||
style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TextField
|
||||
label="Alt text"
|
||||
hint="Describes the image for screen readers and when it fails to load."
|
||||
value={props.alt}
|
||||
maxLength={300}
|
||||
onChange={(alt) => onChange({ ...props, alt })}
|
||||
/>
|
||||
<TextField
|
||||
label="Caption (optional)"
|
||||
value={props.caption}
|
||||
maxLength={500}
|
||||
onChange={(caption) => onChange({ ...props, caption })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Alignment"
|
||||
value={props.alignment || 'center'}
|
||||
onChange={(alignment) => onChange({ ...props, alignment })}
|
||||
options={ALIGN}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'image',
|
||||
version: 1,
|
||||
label: 'Image',
|
||||
icon: '🖼',
|
||||
component: ImageBlock,
|
||||
editor: ImageEditor,
|
||||
defaults: () => ({ src: '', alt: '', caption: '', alignment: 'center' }),
|
||||
})
|
||||
43
client/src/blocks/types/quote.jsx
Normal file
43
client/src/blocks/types/quote.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// quote block — a pull quote with optional attribution.
|
||||
import { registerBlock } from '../registry'
|
||||
import { TextAreaField, TextField } from '../editorKit.jsx'
|
||||
|
||||
function QuoteBlock({ props }) {
|
||||
if (!props.text) return null
|
||||
return (
|
||||
<figure className="page-quote">
|
||||
<blockquote>{props.text}</blockquote>
|
||||
{props.attribution && <figcaption>— {props.attribution}</figcaption>}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteEditor({ props, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<TextAreaField
|
||||
label="Quote"
|
||||
value={props.text}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
<TextField
|
||||
label="Attribution (optional)"
|
||||
value={props.attribution}
|
||||
maxLength={200}
|
||||
onChange={(attribution) => onChange({ ...props, attribution })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'quote',
|
||||
version: 1,
|
||||
label: 'Quote',
|
||||
icon: '❝',
|
||||
component: QuoteBlock,
|
||||
editor: QuoteEditor,
|
||||
defaults: () => ({ text: '', attribution: '' }),
|
||||
})
|
||||
39
client/src/blocks/types/richText.jsx
Normal file
39
client/src/blocks/types/richText.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// rich_text block — HTML from the shared rich-text editor. Rendered inside the
|
||||
// same `.prose` styling as wiki/news bodies, sanitized on render as defense in
|
||||
// depth (the server also sanitizes on save).
|
||||
import { lazy, Suspense } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { registerBlock } from '../registry'
|
||||
|
||||
const RichTextEditor = lazy(() => import('../../components/RichTextEditor.jsx'))
|
||||
|
||||
function RichTextBlock({ props }) {
|
||||
return (
|
||||
<div
|
||||
className="prose page-rich-text"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(props.html || '') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextEditorForm({ props, onChange }) {
|
||||
return (
|
||||
<Suspense fallback={<span className="spin" />}>
|
||||
<RichTextEditor
|
||||
value={props.html || ''}
|
||||
onChange={(html) => onChange({ ...props, html })}
|
||||
variant="post"
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'rich_text',
|
||||
version: 1,
|
||||
label: 'Rich text',
|
||||
icon: '¶',
|
||||
component: RichTextBlock,
|
||||
editor: RichTextEditorForm,
|
||||
defaults: () => ({ html: '' }),
|
||||
})
|
||||
120
client/src/blocks/types/twoColumn.jsx
Normal file
120
client/src/blocks/types/twoColumn.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// two_column block — the only container. Holds two ordered arrays of sub-blocks
|
||||
// (`left`, `right`). Sub-blocks are leaf blocks only (no nested containers — the
|
||||
// one-level cap the server also enforces), so the column editor's palette is the
|
||||
// set of non-container registered blocks.
|
||||
import { registerBlock, getBlock, listBlocks, makeBlockId } from '../registry'
|
||||
import BlockRenderer from '../BlockRenderer.jsx'
|
||||
|
||||
// ── Renderer ──────────────────────────────────────────────────────────
|
||||
function TwoColumnBlock({ props }) {
|
||||
const left = Array.isArray(props.left) ? props.left : []
|
||||
const right = Array.isArray(props.right) ? props.right : []
|
||||
return (
|
||||
<div className="page-two-column">
|
||||
<div className="page-column">
|
||||
{left.map((b) => (
|
||||
<BlockRenderer key={b.id} block={b} />
|
||||
))}
|
||||
</div>
|
||||
<div className="page-column">
|
||||
{right.map((b) => (
|
||||
<BlockRenderer key={b.id} block={b} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Column editor ─────────────────────────────────────────────────────
|
||||
// Manages one side's array: add (from the leaf palette), edit each via its own
|
||||
// registry editor, reorder, remove.
|
||||
function ColumnEditor({ title, items, onChange }) {
|
||||
const list = Array.isArray(items) ? items : []
|
||||
const palette = listBlocks().filter((b) => !b.container)
|
||||
|
||||
function addBlock(type) {
|
||||
const def = getBlock(type)
|
||||
if (!def) return
|
||||
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
|
||||
onChange([...list, block])
|
||||
}
|
||||
function updateAt(i, nextProps) {
|
||||
onChange(list.map((b, j) => (j === i ? { ...b, props: nextProps } : b)))
|
||||
}
|
||||
function removeAt(i) {
|
||||
onChange(list.filter((_, j) => j !== i))
|
||||
}
|
||||
function move(i, dir) {
|
||||
const j = i + dir
|
||||
if (j < 0 || j >= list.length) return
|
||||
const next = [...list]
|
||||
;[next[i], next[j]] = [next[j], next[i]]
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-column-editor">
|
||||
<div className="pb-column-head">
|
||||
<span className="field-label" style={{ margin: 0 }}>{title}</span>
|
||||
<select
|
||||
className="select pb-add-select"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) addBlock(e.target.value)
|
||||
e.target.value = ''
|
||||
}}
|
||||
>
|
||||
<option value="">+ Add block…</option>
|
||||
{palette.map((b) => (
|
||||
<option key={b.type} value={b.type}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{list.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>Empty column.</p>
|
||||
)}
|
||||
|
||||
{list.map((block, i) => {
|
||||
const def = getBlock(block.type)
|
||||
const Editor = def?.editor
|
||||
return (
|
||||
<div key={block.id} className="pb-subblock">
|
||||
<div className="pb-subblock-head">
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem' }}>{def?.label || block.type}</span>
|
||||
<div className="pb-subblock-actions">
|
||||
<button type="button" className="pill pb-mini" disabled={i === 0} onClick={() => move(i, -1)} title="Move up">↑</button>
|
||||
<button type="button" className="pill pb-mini" disabled={i === list.length - 1} onClick={() => move(i, 1)} title="Move down">↓</button>
|
||||
<button type="button" className="pill pb-mini" onClick={() => removeAt(i)} title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{Editor && <Editor props={block.props || {}} onChange={(p) => updateAt(i, p)} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TwoColumnEditor({ props, onChange }) {
|
||||
return (
|
||||
<div className="pb-two-column-editor">
|
||||
<ColumnEditor title="Left column" items={props.left} onChange={(left) => onChange({ ...props, left })} />
|
||||
<ColumnEditor title="Right column" items={props.right} onChange={(right) => onChange({ ...props, right })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'two_column',
|
||||
version: 1,
|
||||
label: 'Two columns',
|
||||
icon: '▥',
|
||||
component: TwoColumnBlock,
|
||||
editor: TwoColumnEditor,
|
||||
defaults: () => ({ left: [], right: [] }),
|
||||
container: true,
|
||||
containerSlots: ['left', 'right'],
|
||||
})
|
||||
141
client/src/components/CharacterSheet.jsx
Normal file
141
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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).
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
function StatTile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Vital({ label, cur, max }) {
|
||||
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||
return (
|
||||
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CharacterSheet({ char }) {
|
||||
if (!char) return null
|
||||
const stats = char.stats || {}
|
||||
const resist = stats.resist || {}
|
||||
// Skills the character actually has, best first.
|
||||
const skills = (char.skills || [])
|
||||
.filter((s) => (s.value || s.base || 0) > 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||
const equipment = char.equipment || []
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
{/* Identity */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{char.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||
</div>
|
||||
|
||||
{/* Core stats */}
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||
<div className="grid-3" style={{ gap: 12 }}>
|
||||
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||
</div>
|
||||
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resistances */}
|
||||
{Object.keys(resist).length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{skills.map((s) => {
|
||||
const cap = s.cap || 100
|
||||
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||
return (
|
||||
<div key={s.n}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{equipment.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{equipment.map((it) => (
|
||||
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
||||
</div>
|
||||
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||
{Object.entries(it.mods).map(([k, v]) => (
|
||||
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
client/src/components/CharacterStats.jsx
Normal file
72
client/src/components/CharacterStats.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// A small stat-tile row for a "My Characters" page: total characters, how many
|
||||
// are online right now, and how many game accounts are linked. `scope` is the
|
||||
// shard api object (admin or player self-service). Renders nothing until an
|
||||
// account is linked, so the empty/link-prompt state below it stands alone.
|
||||
//
|
||||
// It fetches the same rosters GameAccounts loads; for a personal page that's at
|
||||
// most a couple of extra live round-trips, and keeps this presentational bit
|
||||
// decoupled from GameAccounts' per-account roster loading.
|
||||
|
||||
function Tile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CharacterStats({ scope }) {
|
||||
const [stats, setStats] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const accounts = await scope.accounts()
|
||||
const linked = accounts.length
|
||||
if (linked === 0) {
|
||||
if (!cancelled) setStats({ linked: 0 })
|
||||
return
|
||||
}
|
||||
// Roster is a live round-trip and can be unavailable (503); tolerate a
|
||||
// partial result so a restarting shard doesn't blank the whole row.
|
||||
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
|
||||
let chars = 0
|
||||
let online = 0
|
||||
let complete = true
|
||||
for (const r of rosters) {
|
||||
if (r.status === 'fulfilled') {
|
||||
const cs = r.value.chars || []
|
||||
chars += cs.length
|
||||
online += cs.filter((c) => c.online).length
|
||||
} else {
|
||||
complete = false
|
||||
}
|
||||
}
|
||||
if (!cancelled) setStats({ linked, chars, online, complete })
|
||||
} catch {
|
||||
if (!cancelled) setStats({ error: true })
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [scope])
|
||||
|
||||
// Hidden until we know an account is linked (or while first loading).
|
||||
if (!stats || stats.error || stats.linked === 0) return null
|
||||
|
||||
// Counts depend on live rosters; show a dash if none came back.
|
||||
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
|
||||
|
||||
return (
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
|
||||
<Tile value={count(stats.chars)} label="Characters" />
|
||||
<Tile value={count(stats.online)} label="Online now" />
|
||||
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
156
client/src/components/GameAccounts.jsx
Normal file
156
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from './PageState.jsx'
|
||||
|
||||
// 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.
|
||||
|
||||
function LinkForm({ scope, onLinked, compact }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!code.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const { account } = await scope.link(code.trim())
|
||||
setMsg(`Linked ${account}.`)
|
||||
setCode('')
|
||||
await onLinked()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
{!compact && <span className="field-label">Link code</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="AB12CD"
|
||||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Linking…' : 'Link account'}
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountRoster({ scope, account, charTo }) {
|
||||
const [roster, setRoster] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(''); setUnavailable(false)
|
||||
try {
|
||||
setRoster(await scope.roster(account))
|
||||
} catch (err) {
|
||||
if (err.status === 503) setUnavailable(true)
|
||||
else setError(err.message || 'Could not load this account.')
|
||||
}
|
||||
}, [scope, account])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||
<button className="pill" onClick={load}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||
|
||||
const chars = roster.chars || []
|
||||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||
|
||||
return (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{chars.map((c) => (
|
||||
<Link
|
||||
key={c.serial}
|
||||
to={charTo(c.serial)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||
{(c.name || '?').charAt(0)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo }) {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setAccounts(await scope.accounts())
|
||||
} catch {
|
||||
setError('Could not load your game accounts.')
|
||||
}
|
||||
}, [scope])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return <Loading />
|
||||
|
||||
// Not linked yet — prompt to link.
|
||||
if (accounts.length === 0) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked — characters grouped by account.
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
||||
|
||||
@@ -34,7 +35,19 @@ function TextBlock({ props }) {
|
||||
return (
|
||||
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||
{(props.lines || []).map((line, i) => {
|
||||
const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p'
|
||||
const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p'
|
||||
// A rich-text line (e.g. the homepage teaser) carries sanitized HTML;
|
||||
// sanitize again on render as defense in depth. Others render as text.
|
||||
if (line.html) {
|
||||
return (
|
||||
<Tag
|
||||
key={i}
|
||||
className="hero-rich"
|
||||
style={lineStyle(line)}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag key={i} style={lineStyle(line)}>
|
||||
{line.text}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Toolbar button.
|
||||
@@ -25,6 +26,22 @@ function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
||||
}
|
||||
|
||||
// Alignment glyph: three lines justified to the given side.
|
||||
function AlignIcon({ align }) {
|
||||
const rows = {
|
||||
left: [[2, 14], [2, 10], [2, 12]],
|
||||
center: [[2, 14], [4, 12], [3, 13]],
|
||||
right: [[2, 14], [6, 14], [4, 14]],
|
||||
}[align]
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
|
||||
{rows.map(([x1, x2], i) => (
|
||||
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Toolbar variants:
|
||||
// 'full' — every control, incl. the internal wiki-page link picker (wiki use).
|
||||
// 'post' — full minus the wiki-page picker (no page-list context in posts).
|
||||
@@ -42,6 +59,11 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
|
||||
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
Image.configure({ inline: false }),
|
||||
// Alignment stored as `text-align` on the block node (heading/paragraph),
|
||||
// so it round-trips through save/reload as inline style. Shared here means
|
||||
// every consumer — post editor, and the future rich_text / two_column
|
||||
// blocks — gets it for free.
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
],
|
||||
content: value || '',
|
||||
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||
@@ -131,6 +153,16 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
|
||||
—
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Align left" active={editor.isActive({ textAlign: 'left' })} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
|
||||
<AlignIcon align="left" />
|
||||
</Btn>
|
||||
<Btn title="Align center" active={editor.isActive({ textAlign: 'center' })} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
|
||||
<AlignIcon align="center" />
|
||||
</Btn>
|
||||
<Btn title="Align right" active={editor.isActive({ textAlign: 'right' })} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
|
||||
<AlignIcon align="right" />
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
||||
🔗
|
||||
</Btn>
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
|
||||
const NAV = {
|
||||
website: [
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
],
|
||||
wiki: [
|
||||
{ label: 'Website', to: '/site' },
|
||||
{ label: 'New Player Guide', to: '/wiki/new-player-guide' },
|
||||
{ label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
|
||||
{ label: 'Systems', to: '/wiki/systems' },
|
||||
{ label: 'Rules', to: '/wiki/rules' },
|
||||
],
|
||||
}
|
||||
// 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).
|
||||
const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
const linkStyle = ({ isActive }) => ({
|
||||
background: isActive ? 'var(--accent)' : undefined,
|
||||
color: isActive ? 'var(--bg-deep)' : undefined,
|
||||
borderColor: isActive ? 'var(--accent)' : undefined,
|
||||
})
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
const account =
|
||||
user && user.role && user.role !== 'player'
|
||||
? { label: 'Admin', to: '/admin' }
|
||||
: user
|
||||
? { label: 'My Account', to: '/player' }
|
||||
: { label: 'Sign in', to: '/account/login' }
|
||||
|
||||
export default function SiteHeader({ section = 'website' }) {
|
||||
const links = NAV[section] || NAV.website
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
@@ -34,38 +45,31 @@ export default function SiteHeader({ section = 'website' }) {
|
||||
>
|
||||
<div
|
||||
className="shell"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 20,
|
||||
padding: '14px 0',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, padding: '14px 0', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="display"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: '1.2rem',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'var(--accent-bright)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||
>
|
||||
<MoonDot />
|
||||
UOMysticmoon
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{links.map((l) => (
|
||||
<Link key={l.to + l.label} to={l.to} className="pill">
|
||||
{NAV.map((l) => (
|
||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</Link>
|
||||
</NavLink>
|
||||
))}
|
||||
{!loading && (
|
||||
<NavLink
|
||||
to={account.to}
|
||||
className="pill"
|
||||
style={{ marginLeft: 6, borderColor: 'var(--accent)', color: 'var(--accent-bright)' }}
|
||||
>
|
||||
{account.label}
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ago } from '../lib/format.js'
|
||||
|
||||
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
|
||||
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
|
||||
// sales for accounts linked to the caller.
|
||||
export default function VendorSales({ fetchSales }) {
|
||||
const [sales, setSales] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetchSales()
|
||||
.then((rows) => active && setSales(rows))
|
||||
.catch(() => active && setError('Could not load your vendor sales.'))
|
||||
return () => { active = false }
|
||||
}, [fetchSales])
|
||||
|
||||
if (error) return null
|
||||
if (!sales) return null
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||
{sales.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sales.map((s, i) => (
|
||||
<li key={`${s.t}-${i}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function defaultLayout(teaser) {
|
||||
{ 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: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
||||
{ text: teaser, tag: 'p', fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
88
client/src/lib/shardEvents.js
Normal file
88
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Shared formatting for shard events — used by the public Shard page, the
|
||||
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||
// and which category/badge it belongs to.
|
||||
|
||||
function nameOf(who) {
|
||||
if (!who) return 'Someone'
|
||||
if (typeof who === 'string') return who
|
||||
return who.name || who.acct || 'Someone'
|
||||
}
|
||||
|
||||
const n = (v) => Number(v || 0).toLocaleString()
|
||||
|
||||
// A one-line human description of an event. Accepts either a stored event
|
||||
// (with .payload) or a raw live frame (fields at top level).
|
||||
export function describe(ev) {
|
||||
const p = ev.payload || ev
|
||||
switch (ev.kind) {
|
||||
case 'vendor.sale':
|
||||
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp`
|
||||
case 'player.death':
|
||||
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
|
||||
case 'player.murdered':
|
||||
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
|
||||
case 'mob.killed':
|
||||
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
|
||||
case 'skill.gain':
|
||||
return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
|
||||
case 'fame.change':
|
||||
return `${nameOf(p.who)}’s fame changed to ${n(p.new)}`
|
||||
case 'karma.change':
|
||||
return `${nameOf(p.who)}’s karma changed to ${n(p.new)}`
|
||||
case 'quest.complete':
|
||||
return `${nameOf(p.who)} completed “${p.quest}”`
|
||||
case 'house.decay':
|
||||
return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}`
|
||||
case 'mob.login':
|
||||
return `${nameOf(p.who)} entered the world`
|
||||
case 'mob.logout':
|
||||
return `${nameOf(p.who)} left the world`
|
||||
case 'economy.supply':
|
||||
return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
|
||||
case 'server.hello':
|
||||
return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
|
||||
case 'server.shutdown':
|
||||
return 'Shard shut down'
|
||||
case 'server.crashed':
|
||||
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
|
||||
// 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})`
|
||||
case 'audit.command':
|
||||
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
|
||||
case 'cheat.fastwalk':
|
||||
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
|
||||
case 'account.login.attempt':
|
||||
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
|
||||
case 'gold.change':
|
||||
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}`
|
||||
default:
|
||||
return ev.kind
|
||||
}
|
||||
}
|
||||
|
||||
// Category grouping for the filter tabs.
|
||||
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||
// (a linked player sees their own under the portal). The admin live feed still
|
||||
// describes vendor.sale via describe() below.
|
||||
export const CATEGORIES = [
|
||||
{ id: 'all', label: 'All', kinds: null },
|
||||
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||
]
|
||||
|
||||
const CATEGORY_OF = (() => {
|
||||
const m = {}
|
||||
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||
return m
|
||||
})()
|
||||
|
||||
export function categoryOf(kind) {
|
||||
return CATEGORY_OF[kind] || 'other'
|
||||
}
|
||||
|
||||
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||
export function kindLabel(kind) {
|
||||
return String(kind || '').replace(/[._]/g, ' ')
|
||||
}
|
||||
54
client/src/lib/useShardFeed.js
Normal file
54
client/src/lib/useShardFeed.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
|
||||
// of the most recent events. The browser talks to our own /public/shard/stream
|
||||
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
|
||||
// stays server-side and it works through any reverse proxy.
|
||||
//
|
||||
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
|
||||
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||
// buffer length.
|
||||
export function useShardFeed({ url, filter, max = 40 } = {}) {
|
||||
const [events, setEvents] = useState([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||
const filterRef = useRef(filter)
|
||||
filterRef.current = filter
|
||||
const streamUrl = url || api.shardStreamUrl
|
||||
|
||||
useEffect(() => {
|
||||
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||
// "no live feed" rather than throwing.
|
||||
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||
|
||||
const es = new EventSource(streamUrl, { withCredentials: true })
|
||||
|
||||
es.onopen = () => setConnected(true)
|
||||
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||
|
||||
es.onmessage = (msg) => {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(msg.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!event || !event.kind) return
|
||||
const f = filterRef.current
|
||||
if (f && !f.has(event.kind)) return
|
||||
setEvents((prev) => {
|
||||
// Tag with a stable-ish local id for React keys (events carry t but can
|
||||
// collide within a ms) and cap the buffer.
|
||||
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
|
||||
return next.slice(0, max)
|
||||
})
|
||||
}
|
||||
|
||||
return () => es.close()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [max, streamUrl])
|
||||
|
||||
return { events, connected }
|
||||
}
|
||||
@@ -1,37 +1,105 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
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'
|
||||
|
||||
// `roles` (when present) restricts which roles see a nav item. Items without it
|
||||
// are shown to admin/editor as before. Moderators are further confined to just
|
||||
// their own section + account security (see the redirect effect below).
|
||||
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||
function Icon({ children, size = 16 }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
const IconHome = () => <Icon><path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></Icon>
|
||||
const IconPosts = () => <Icon><path d="M5 3h14v18H5z" /><path d="M8 8h8M8 12h8M8 16h5" /></Icon>
|
||||
const IconWiki = () => <Icon><path d="M4 4h9a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z" /><path d="M20 4h-2a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h2z" /></Icon>
|
||||
const IconPages = () => <Icon><path d="M5 3h9l5 5v13H5z" /><path d="M14 3v5h5" /><path d="M8 13h8M8 17h8" /></Icon>
|
||||
const IconActivity = () => <Icon><path d="M3 12h4l3 8 4-16 3 8h4" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
const IconUsers = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20a6 6 0 0 1 12 0" /><path d="M16 6a3 3 0 0 1 0 6M17 20a6 6 0 0 0-3-5" /></Icon>
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconHero = () => <Icon><path d="M3 5h18v14H3z" /><circle cx="8" cy="10" r="1.6" /><path d="M4 18l5-5 3 3 3-4 5 6" /></Icon>
|
||||
const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18 12v3M15 12v2" /></Icon>
|
||||
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
// (when present) matches server-side enforcement so the sidebar never shows a
|
||||
// link that would 403; an item without `roles` is visible to everyone.
|
||||
// Moderators are further confined to just their section + account (see below).
|
||||
const NAV = [
|
||||
{ to: '/admin', label: 'Dashboard', end: true },
|
||||
{ to: '/admin/posts', label: 'Posts' },
|
||||
{ to: '/admin/wiki', label: 'Wiki' },
|
||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/settings', label: 'Settings' },
|
||||
{ to: '/admin/activity', label: 'Activity' },
|
||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||
{ to: '/admin/discord-bot', label: 'Discord Bot' },
|
||||
{ to: '/admin/auth-providers', label: 'Authentication' },
|
||||
{ to: '/admin/users', label: 'Users' },
|
||||
{ to: '/admin/account', label: 'Account' },
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
items: [
|
||||
{ to: '/admin/posts', label: 'Posts', icon: IconPosts, roles: ['admin', 'editor'] },
|
||||
{ to: '/admin/pages', label: 'Pages', icon: IconPages, roles: ['admin', 'editor'] },
|
||||
{ to: '/admin/wiki', label: 'Wiki', icon: IconWiki, roles: ['admin', 'editor'] },
|
||||
{ to: '/admin/activity', label: 'Activity', icon: IconActivity, roles: ['admin', 'editor'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Moderation',
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ to: '/admin/users', label: 'Users', 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'] },
|
||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
|
||||
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||
|
||||
const TITLES = {
|
||||
'/admin': 'Dashboard',
|
||||
'/admin/posts': 'Posts',
|
||||
'/admin/pages': 'Pages',
|
||||
'/admin/wiki': 'Wiki Pages',
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Bot Activity',
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
'/admin/discord-bot': 'Discord Bot',
|
||||
'/admin/shard': 'Shard (uo-link)',
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
'/admin/account': 'Account Security',
|
||||
@@ -44,7 +112,9 @@ const navBtnBase = {
|
||||
fontFamily: 'var(--sans)',
|
||||
fontSize: '0.92rem',
|
||||
textDecoration: 'none',
|
||||
display: 'block',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
transition: 'background .15s,color .15s',
|
||||
}
|
||||
|
||||
@@ -55,18 +125,51 @@ export default function AdminLayout() {
|
||||
const location = useLocation()
|
||||
const title =
|
||||
TITLES[location.pathname] ||
|
||||
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
||||
(location.pathname.startsWith('/admin/moderation')
|
||||
? 'Moderation'
|
||||
: location.pathname.startsWith('/admin/characters')
|
||||
? 'My Characters'
|
||||
: '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.
|
||||
const isModerator = user?.role === 'moderator'
|
||||
const navItems = NAV.filter((n) => {
|
||||
if (n.roles && !n.roles.includes(user?.role)) return false
|
||||
if (isModerator) return n.to === '/admin/moderation' || n.to === '/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'
|
||||
return true
|
||||
}
|
||||
// Drop items the current role can't see, then drop any now-empty group so an
|
||||
// empty category header never renders.
|
||||
const navGroups = NAV
|
||||
.map((g) => ({ ...g, items: g.items.filter(visible) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
|
||||
// Accordion: track which titled categories are collapsed. Persist across
|
||||
// reloads; default all-open. The group holding the active route auto-opens.
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
const toggleGroup = (title) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = { ...prev, [title]: !prev[title] }
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
/* private mode / quota — collapse is non-essential */
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
const activeGroupTitle = navGroups.find((g) =>
|
||||
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||
)?.title
|
||||
|
||||
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||
@@ -118,22 +221,71 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{navItems.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
style={({ isActive }) => ({
|
||||
...navBtnBase,
|
||||
background: isActive ? 'var(--blue)' : 'transparent',
|
||||
color: isActive ? 'var(--ink)' : 'var(--muted)',
|
||||
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||
})}
|
||||
>
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{navGroups.map((group, gi) => {
|
||||
const links = group.items.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className="admin-nav-link"
|
||||
style={({ isActive }) => ({
|
||||
...navBtnBase,
|
||||
background: isActive ? 'var(--blue)' : 'transparent',
|
||||
color: isActive ? 'var(--ink)' : 'var(--muted)',
|
||||
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||
})}
|
||||
>
|
||||
{n.icon && <n.icon />}
|
||||
<span>{n.label}</span>
|
||||
</NavLink>
|
||||
))
|
||||
|
||||
// Untitled groups (Dashboard, Account) render their links directly.
|
||||
if (!group.title) {
|
||||
return (
|
||||
<div key={`g${gi}`} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{links}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Titled groups get a collapsible header. The group with the active
|
||||
// route stays open regardless of the stored collapse preference.
|
||||
const isOpen = group.title === activeGroupTitle || !collapsed[group.title]
|
||||
return (
|
||||
<div key={group.title} className="admin-nav-group">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-nav-head sans"
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
<svg
|
||||
className="admin-nav-chev"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="admin-nav-items" style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{links}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
|
||||
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import CharacterSheet from '../../../components/CharacterSheet.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// A staff member's own character sheet inside the admin shell. Owner-checked —
|
||||
// the endpoint only returns a sheet for a character on the caller's linked account.
|
||||
export default function AdminCharacter() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
|
||||
const restarting = error && error.status === 503
|
||||
const forbidden = error && error.status === 403
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to my characters
|
||||
</Link>
|
||||
</p>
|
||||
{loading && <Loading />}
|
||||
{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} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
18
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../../components/VendorSales.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Staff link their OWN in-game account and view their characters — the same
|
||||
// shared component players use, pointed at the staff self-service endpoints.
|
||||
// Sits inside the Admin shell, which supplies the "My Characters" page header;
|
||||
// stat tiles bring it to parity with the Player Portal's Characters page.
|
||||
export default function AdminCharacters() {
|
||||
return (
|
||||
<section style={{ maxWidth: 760 }}>
|
||||
<CharacterStats scope={api.admin.shard} />
|
||||
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
243
client/src/routes/admin/views/EmailDelivery.jsx
Normal file
243
client/src/routes/admin/views/EmailDelivery.jsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// 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
|
||||
// captures a refresh token server-side — the token is write-only over the API
|
||||
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
|
||||
// requires the Google provider to be configured on the Authentication page first.
|
||||
|
||||
const STATUS_COLOR = {
|
||||
connected: '#7fd0a4',
|
||||
error: '#d98b84',
|
||||
unconfigured: 'var(--muted)',
|
||||
}
|
||||
|
||||
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
|
||||
const ERROR_TEXT = {
|
||||
denied: 'Google sign-in was cancelled or denied.',
|
||||
bad_state: 'The connect session expired. Please try again.',
|
||||
no_client: 'The Google OAuth client is not configured.',
|
||||
no_refresh_token:
|
||||
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
|
||||
no_email: 'Could not read the Gmail address from Google.',
|
||||
error: 'Could not connect the Gmail account. Please try again.',
|
||||
}
|
||||
|
||||
function StatusPanel({ config }) {
|
||||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||
{config.status || 'unconfigured'}
|
||||
</span>
|
||||
</div>
|
||||
{config.senderEmail && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: 'var(--ink)' }}>
|
||||
Sending as <strong>{config.senderEmail}</strong>
|
||||
</p>
|
||||
)}
|
||||
{config.statusDetail && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||
)}
|
||||
{config.lastVerifiedAt && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
Last verified: {new Date(config.lastVerifiedAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EmailDelivery() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [senderName, setSenderName] = useState('')
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [banner, setBanner] = useState(null) // { kind: 'ok'|'err', text }
|
||||
|
||||
const load = useCallback(async (seedForm = false) => {
|
||||
try {
|
||||
const c = await api.admin.getEmailConfig()
|
||||
setConfig(c)
|
||||
if (seedForm) {
|
||||
setSenderName(c.senderName || '')
|
||||
setEnabled(c.enabled)
|
||||
}
|
||||
return c
|
||||
} catch {
|
||||
setError('Could not load email settings.')
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// On mount, surface the outcome of a just-completed connect redirect, strip the
|
||||
// query params so a refresh doesn't replay the banner, then load config.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('email_connected')) {
|
||||
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
|
||||
} else if (params.has('email_error')) {
|
||||
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
|
||||
}
|
||||
if (params.has('email_connected') || params.has('email_error')) {
|
||||
params.delete('email_connected')
|
||||
params.delete('email_error')
|
||||
const qs = params.toString()
|
||||
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||
}
|
||||
load(true)
|
||||
}, [load])
|
||||
|
||||
async function connect() {
|
||||
setBusy('connect')
|
||||
setActionError('')
|
||||
try {
|
||||
const { url } = await api.admin.emailConnectUrl()
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not start the connect flow.')
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy('save')
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
try {
|
||||
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
|
||||
setConfig(saved)
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setBusy('test')
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
try {
|
||||
const r = await api.admin.testEmail()
|
||||
setMsg(`Test email sent to ${r.to}.`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not send the test email.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
setBusy('disconnect')
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
try {
|
||||
const c = await api.admin.disconnectEmail()
|
||||
setConfig(c)
|
||||
setEnabled(false)
|
||||
setMsg('Disconnected.')
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not disconnect.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
|
||||
if (!config) return null
|
||||
|
||||
const connected = config.hasRefreshToken
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
|
||||
<div>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
|
||||
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
|
||||
Sends the contact form through Gmail over OAuth2, delivered to the
|
||||
<strong> Contact email</strong> above. Reuses the Google authentication
|
||||
client — configure that on the Authentication page first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{banner && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
|
||||
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
|
||||
}}
|
||||
>
|
||||
{banner.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusPanel config={config} />
|
||||
|
||||
{!config.googleConfigured && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
|
||||
The Google authentication provider needs a client ID and secret before
|
||||
you can connect a Gmail account.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!connected ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
|
||||
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enable email sending
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">From display name (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="UOMysticmoon"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
|
||||
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
<button onClick={connect} disabled={busy === 'connect'} className="pill">
|
||||
Reconnect
|
||||
</button>
|
||||
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ minHeight: 18 }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{actionError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{actionError}</span>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
453
client/src/routes/admin/views/PageBuilder.jsx
Normal file
453
client/src/routes/admin/views/PageBuilder.jsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import Modal from '../../../components/Modal.jsx'
|
||||
import { Loading } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import '../../../blocks/index.js' // registers all block types
|
||||
import { listBlocks, getBlock, makeBlockId } from '../../../blocks/registry.js'
|
||||
import { SelectField, TextField, TextAreaField } from '../../../blocks/editorKit.jsx'
|
||||
|
||||
const LAYOUTS = [
|
||||
['default', 'Default'],
|
||||
['full_width', 'Full width'],
|
||||
['landing', 'Landing'],
|
||||
]
|
||||
const NAV_GROUPS = [
|
||||
['', 'None'],
|
||||
['main', 'Main nav'],
|
||||
['footer', 'Footer'],
|
||||
['account', 'Account'],
|
||||
['hidden', 'Hidden'],
|
||||
]
|
||||
|
||||
const EMPTY = {
|
||||
title: '',
|
||||
slug: '',
|
||||
status: 'draft',
|
||||
blocks: [],
|
||||
metadata: { seoTitle: '', metaDescription: '', ogImage: '', canonicalUrl: '', robots: '' },
|
||||
settings: { layout: 'default', showInNav: false, navGroup: '', navOrder: null, protected: false },
|
||||
}
|
||||
|
||||
// Map an API page (grouped shape) into local editable form state.
|
||||
function toForm(page) {
|
||||
return {
|
||||
title: page.title || '',
|
||||
slug: page.slug || '',
|
||||
status: page.status || 'draft',
|
||||
blocks: Array.isArray(page.blocks) ? page.blocks : [],
|
||||
metadata: { ...EMPTY.metadata, ...cleanNulls(page.metadata) },
|
||||
settings: {
|
||||
layout: page.settings?.layout || 'default',
|
||||
showInNav: Boolean(page.settings?.showInNav),
|
||||
navGroup: page.settings?.navGroup || '',
|
||||
navOrder: page.settings?.navOrder ?? null,
|
||||
protected: Boolean(page.settings?.protected),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function cleanNulls(obj) {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(obj || {})) out[k] = v == null ? '' : v
|
||||
return out
|
||||
}
|
||||
|
||||
export default function PageBuilder() {
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [form, setForm] = useState(EMPTY)
|
||||
const [protectedNow, setProtectedNow] = useState(false) // server truth, edit mode
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [details, setDetails] = useState([]) // block validation errors
|
||||
const [notice, setNotice] = useState('')
|
||||
const [tab, setTab] = useState('content')
|
||||
const [pwModal, setPwModal] = useState(false)
|
||||
const [dragIndex, setDragIndex] = useState(null)
|
||||
|
||||
const palette = useMemo(() => listBlocks(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) return
|
||||
let active = true
|
||||
setLoading(true)
|
||||
api.admin
|
||||
.getPage(id)
|
||||
.then((page) => {
|
||||
if (!active) return
|
||||
setForm(toForm(page))
|
||||
setProtectedNow(Boolean(page.settings?.protected))
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return
|
||||
setError(err.message || 'Could not load the page.')
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id, isEdit])
|
||||
|
||||
// ── Block operations ────────────────────────────────────────────────
|
||||
const addBlock = useCallback((type) => {
|
||||
const def = getBlock(type)
|
||||
if (!def) return
|
||||
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
|
||||
setForm((f) => ({ ...f, blocks: [...f.blocks, block] }))
|
||||
}, [])
|
||||
|
||||
const updateBlock = useCallback((blockId, nextProps) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, props: nextProps } : b)),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const toggleVisible = useCallback((blockId) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, visible: b.visible === false } : b)),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const removeBlock = useCallback((blockId) => {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.filter((b) => b.id !== blockId) }))
|
||||
}, [])
|
||||
|
||||
const moveBlock = useCallback((from, to) => {
|
||||
setForm((f) => {
|
||||
if (to < 0 || to >= f.blocks.length) return f
|
||||
const next = [...f.blocks]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved)
|
||||
return { ...f, blocks: next }
|
||||
})
|
||||
}, [])
|
||||
|
||||
function onDrop(index) {
|
||||
if (dragIndex === null || dragIndex === index) return setDragIndex(null)
|
||||
moveBlock(dragIndex, index)
|
||||
setDragIndex(null)
|
||||
}
|
||||
|
||||
// ── Form field setters ──────────────────────────────────────────────
|
||||
const setField = (k) => (v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
const setMeta = (k) => (v) => setForm((f) => ({ ...f, metadata: { ...f.metadata, [k]: v } }))
|
||||
const setSetting = (k) => (v) => setForm((f) => ({ ...f, settings: { ...f.settings, [k]: v } }))
|
||||
|
||||
// Serialize local state into an API payload. Empty metadata strings become
|
||||
// null; navGroup '' becomes null.
|
||||
function payload() {
|
||||
const metadata = {}
|
||||
for (const [k, v] of Object.entries(form.metadata)) metadata[k] = v === '' ? null : v
|
||||
const settings = {
|
||||
layout: form.settings.layout,
|
||||
showInNav: Boolean(form.settings.showInNav),
|
||||
navGroup: form.settings.navGroup === '' ? null : form.settings.navGroup,
|
||||
navOrder: form.settings.navOrder === '' || form.settings.navOrder == null ? null : Number(form.settings.navOrder),
|
||||
}
|
||||
return { title: form.title.trim(), status: form.status, blocks: form.blocks, metadata, settings }
|
||||
}
|
||||
|
||||
async function save({ silent } = {}) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setDetails([])
|
||||
setNotice('')
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.admin.updatePage(id, payload())
|
||||
if (!silent) setNotice('Saved.')
|
||||
} else {
|
||||
if (!form.slug.trim()) throw new Error('A slug is required.')
|
||||
const created = await api.admin.createPage({ slug: form.slug.trim(), ...payload() })
|
||||
navigate(`/admin/pages/${created.id}`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the page.')
|
||||
if (err.body?.details) setDetails(err.body.details)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePublish() {
|
||||
const next = form.status === 'published' ? 'draft' : 'published'
|
||||
setForm((f) => ({ ...f, status: next }))
|
||||
// Persist immediately (edit mode) so the status change isn't lost.
|
||||
if (isEdit) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updatePage(id, { ...payload(), status: next })
|
||||
setNotice(next === 'published' ? 'Published.' : 'Unpublished.')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not change status.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function protectPage() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updatePage(id, { settings: { protected: true } })
|
||||
setProtectedNow(true)
|
||||
setForm((f) => ({ ...f, settings: { ...f.settings, protected: true } }))
|
||||
setNotice('Page protected.')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not protect the page.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function unprotectPage(password) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.unprotectPage(id, password)
|
||||
setProtectedNow(false)
|
||||
setForm((f) => ({ ...f, settings: { ...f.settings, protected: false } }))
|
||||
setPwModal(false)
|
||||
setNotice('Protection removed.')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unprotect the page.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
setError('')
|
||||
try {
|
||||
const { token } = await api.admin.createPagePreview(id)
|
||||
window.open(`/preview/${id}/${token}`, '_blank', 'noopener')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not create a preview link.')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('Delete this page? This cannot be undone.')) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.deletePage(id)
|
||||
navigate('/admin/pages')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not delete the page.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
|
||||
const published = form.status === 'published'
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Toolbar */}
|
||||
<div className="pb-toolbar">
|
||||
<button className="pill" onClick={() => navigate('/admin/pages')}>← Pages</button>
|
||||
<span className={`badge ${published ? 'badge-pub' : 'badge-draft'}`}>{published ? 'Published' : 'Draft'}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
{isEdit && (
|
||||
<button className="pill" onClick={preview} disabled={busy}>Preview</button>
|
||||
)}
|
||||
{isEdit && (
|
||||
<button className="pill" onClick={togglePublish} disabled={busy}>
|
||||
{published ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}>
|
||||
{busy ? 'Saving…' : isEdit ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="pb-error sans">
|
||||
{error}
|
||||
{details.length > 0 && (
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||
{details.map((d, i) => <li key={i}>{d}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="pb-notice sans">{notice}</div>}
|
||||
|
||||
{/* Title + slug */}
|
||||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', margin: '16px 0' }}>
|
||||
<label style={{ flex: '2 1 320px' }}>
|
||||
<span className="field-label">Title</span>
|
||||
<input className="input" value={form.title} onChange={(e) => setField('title')(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Slug {isEdit && '(fixed)'}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.slug}
|
||||
disabled={isEdit}
|
||||
placeholder="my-page"
|
||||
onChange={(e) => setField('slug')(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="pb-tabs">
|
||||
<button className={`pb-tab ${tab === 'content' ? 'is-active' : ''}`} onClick={() => setTab('content')}>Content</button>
|
||||
<button className={`pb-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Settings & SEO</button>
|
||||
</div>
|
||||
|
||||
{tab === 'content' && (
|
||||
<>
|
||||
<div className="pb-palette">
|
||||
<span className="field-label" style={{ margin: '0 6px 0 0' }}>Add block</span>
|
||||
{palette.map((b) => (
|
||||
<button key={b.type} className="pill" onClick={() => addBlock(b.type)} disabled={busy}>
|
||||
<span aria-hidden style={{ marginRight: 6 }}>{b.icon}</span>{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pb-canvas">
|
||||
{form.blocks.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', padding: 30 }}>
|
||||
No blocks yet — add one from the palette above.
|
||||
</p>
|
||||
)}
|
||||
{form.blocks.map((block, i) => {
|
||||
const def = getBlock(block.type)
|
||||
const Editor = def?.editor
|
||||
const hidden = block.visible === false
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className={`pb-block-card ${hidden ? 'is-hidden' : ''} ${dragIndex === i ? 'is-dragging' : ''}`}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(i)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(i)}
|
||||
onDragEnd={() => setDragIndex(null)}
|
||||
>
|
||||
<div className="pb-block-head">
|
||||
<span className="pb-drag" title="Drag to reorder">⠿</span>
|
||||
<strong className="sans">{def?.label || block.type}</strong>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="pill pb-mini" title={hidden ? 'Show' : 'Hide'} onClick={() => toggleVisible(block.id)}>
|
||||
{hidden ? '🙈' : '👁'}
|
||||
</button>
|
||||
<button className="pill pb-mini" disabled={i === 0} onClick={() => moveBlock(i, i - 1)} title="Move up">↑</button>
|
||||
<button className="pill pb-mini" disabled={i === form.blocks.length - 1} onClick={() => moveBlock(i, i + 1)} title="Move down">↓</button>
|
||||
<button className="pill pb-mini" onClick={() => removeBlock(block.id)} title="Remove">✕</button>
|
||||
</div>
|
||||
<div className="pb-block-body">
|
||||
{Editor ? (
|
||||
<Editor props={block.props || {}} onChange={(p) => updateBlock(block.id, p)} />
|
||||
) : (
|
||||
<p className="sans dim">Unknown block type: {block.type}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'settings' && (
|
||||
<div className="pb-settings">
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<p className="card-kicker">SEO & metadata</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
||||
<TextField label="SEO title" value={form.metadata.seoTitle} maxLength={200} onChange={setMeta('seoTitle')} hint="Overrides the page title in the browser tab / search results." />
|
||||
<TextAreaField label="Meta description" value={form.metadata.metaDescription} rows={2} maxLength={400} onChange={setMeta('metaDescription')} />
|
||||
<TextField label="OG image URL" value={form.metadata.ogImage} maxLength={500} onChange={setMeta('ogImage')} />
|
||||
<TextField label="Canonical URL" value={form.metadata.canonicalUrl} maxLength={500} onChange={setMeta('canonicalUrl')} />
|
||||
<TextField label="Robots" value={form.metadata.robots} maxLength={100} onChange={setMeta('robots')} placeholder="index,follow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<p className="card-kicker">Layout & navigation</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
||||
<SelectField label="Layout" value={form.settings.layout} onChange={setSetting('layout')} options={LAYOUTS} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={form.settings.showInNav} onChange={(e) => setSetting('showInNav')(e.target.checked)} />
|
||||
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span>
|
||||
</label>
|
||||
<SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} />
|
||||
<TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<p className="card-kicker">Protection & danger zone</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 8 }}>
|
||||
A protected page can’t be deleted and its protection can only be removed by re-entering your password.
|
||||
</p>
|
||||
{!isEdit && <p className="sans dim" style={{ fontSize: '0.82rem' }}>Save the page first to manage protection.</p>}
|
||||
{isEdit && (
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 10 }}>
|
||||
{protectedNow ? (
|
||||
<button className="pill" onClick={() => setPwModal(true)} disabled={busy}>🔓 Remove protection…</button>
|
||||
) : (
|
||||
<button className="pill" onClick={protectPage} disabled={busy}>🔒 Protect page</button>
|
||||
)}
|
||||
<button className="pill pb-danger" onClick={remove} disabled={busy || protectedNow} title={protectedNow ? 'Unprotect first' : 'Delete'}>
|
||||
Delete page
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pwModal && (
|
||||
<UnprotectModal onCancel={() => setPwModal(false)} onConfirm={unprotectPage} busy={busy} error={error} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function UnprotectModal({ onCancel, onConfirm, busy, error }) {
|
||||
const [pw, setPw] = useState('')
|
||||
return (
|
||||
<Modal
|
||||
title="Confirm your password"
|
||||
onClose={onCancel}
|
||||
width={420}
|
||||
footer={
|
||||
<>
|
||||
<button className="pill" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sq" onClick={() => onConfirm(pw)} disabled={busy || !pw}>
|
||||
{busy ? 'Verifying…' : 'Remove protection'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="sans dim" style={{ marginTop: 0, fontSize: '0.88rem' }}>
|
||||
Removing protection is a sensitive change — re-enter your account password to continue.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
autoFocus
|
||||
value={pw}
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && pw && onConfirm(pw)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.85rem', marginBottom: 0 }}>{error}</p>}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
91
client/src/routes/admin/views/PagesAdmin.jsx
Normal file
91
client/src/routes/admin/views/PagesAdmin.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
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 { shortDate } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// List of CMS pages. Create/edit open the full-page block builder; the builder
|
||||
// owns save/delete/publish so this view is read-only navigation.
|
||||
export default function PagesAdmin() {
|
||||
const navigate = useNavigate()
|
||||
const [tick] = useState(0)
|
||||
const { loading, error, data } = useAsync(() => api.admin.listPages(), [tick])
|
||||
const pages = data || []
|
||||
|
||||
const openNew = useCallback(() => navigate('/admin/pages/new'), [navigate])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, marginBottom: 18 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
Compose pages from blocks. A published page is live at <code>/its-slug</code>.
|
||||
</p>
|
||||
<button onClick={openNew} className="btn btn-primary btn-sq">
|
||||
+ New page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load pages." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Title</th>
|
||||
<th className="adm-th">Slug</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Updated</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No pages yet — create your first one.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{pages.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{p.title}
|
||||
{p.protected && (
|
||||
<span title="Protected" style={{ marginLeft: 8 }}>🔒</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td dim">/{p.slug}</td>
|
||||
<td className="adm-td">
|
||||
<span className={`badge ${p.status === 'published' ? 'badge-pub' : 'badge-draft'}`}>
|
||||
{p.status === 'published' ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td dim">{shortDate(p.updatedAt)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
{p.status === 'published' && (
|
||||
<a
|
||||
className="link-accent"
|
||||
href={`/${p.slug}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ marginRight: 14 }}
|
||||
>
|
||||
View
|
||||
</a>
|
||||
)}
|
||||
<span className="link-accent" onClick={() => navigate(`/admin/pages/${p.id}`)}>
|
||||
Edit
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import Modal from '../../../components/Modal.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
@@ -105,6 +105,8 @@ export default function PostEditor({ post, onClose, onSaved }) {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
|
||||
{isEdit && post.category === 'news' && <AnnouncePanel postId={post.id} />}
|
||||
|
||||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 200px' }}>
|
||||
<span className="field-label">Category</span>
|
||||
@@ -173,3 +175,94 @@ const delStyle = {
|
||||
cursor: 'pointer',
|
||||
marginRight: 'auto',
|
||||
}
|
||||
|
||||
// ── Announcement status panel ────────────────────────────────────────────────
|
||||
// Shows the town-crier + Discord delivery state for a published news post and
|
||||
// offers a per-leg retry (useful after fixing the sidecar / news channel without
|
||||
// re-publishing). Only rendered for news posts in edit mode; renders nothing
|
||||
// until the post has actually been announced (no job row yet → nothing to show).
|
||||
const LEG_META = {
|
||||
towncrier: { label: 'In-game town crier' },
|
||||
discord: { label: 'Discord #news' },
|
||||
}
|
||||
const STATUS_STYLE = {
|
||||
done: { color: '#7bbf8f', label: 'delivered' },
|
||||
pending: { color: '#d9b84a', label: 'pending' },
|
||||
failed: { color: '#d98b84', label: 'failed' },
|
||||
}
|
||||
|
||||
function AnnouncePanel({ postId }) {
|
||||
const [job, setJob] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [retrying, setRetrying] = useState('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setJob(await api.admin.getAnnounce(postId))
|
||||
} catch {
|
||||
setJob(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [postId])
|
||||
|
||||
async function retry(leg) {
|
||||
setRetrying(leg)
|
||||
try {
|
||||
setJob(await api.admin.retryAnnounceLeg(postId, leg))
|
||||
} catch {
|
||||
// leave the current state; the row simply didn't change
|
||||
} finally {
|
||||
setRetrying('')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !job) return null
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||
{['towncrier', 'discord'].map((leg) => {
|
||||
const status = job[`${leg}_status`]
|
||||
const err = job[`${leg}_last_error`]
|
||||
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
||||
return (
|
||||
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
|
||||
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
||||
{status !== 'done' && (
|
||||
<button
|
||||
onClick={() => retry(leg)}
|
||||
disabled={Boolean(retrying)}
|
||||
className="pill"
|
||||
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '3px 12px' }}
|
||||
>
|
||||
{retrying === leg ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status === 'failed' && err && (
|
||||
<span className="sans" style={{ fontSize: '0.75rem', color: '#d98b84', paddingLeft: 148 }}>{err}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const panelStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import EmailDelivery from './EmailDelivery.jsx'
|
||||
|
||||
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||||
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||
|
||||
// Editable settings shown on this screen (key -> label + control type).
|
||||
const FIELDS = [
|
||||
{ key: 'site_title', label: 'Site title' },
|
||||
{ key: 'homepage_teaser', label: 'Homepage teaser', long: true },
|
||||
{
|
||||
key: 'homepage_teaser',
|
||||
label: 'Homepage teaser',
|
||||
rich: true,
|
||||
help: 'Rich text shown under the hero heading on the portal (when no custom hero layout is published).',
|
||||
},
|
||||
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
|
||||
{ key: 'status_message', label: 'Status message' },
|
||||
{ key: 'contact_email', label: 'Contact email' },
|
||||
{
|
||||
key: 'contact_email',
|
||||
label: 'Contact email',
|
||||
help: 'Where contact-form messages (and test emails) are delivered. Also the address shown when email delivery is unconfigured and the form falls back to a mailto: link.',
|
||||
},
|
||||
{
|
||||
key: 'player_registration',
|
||||
label: 'Player registration',
|
||||
@@ -54,10 +67,13 @@ export default function SettingsAdmin() {
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const set = (k) => (e) => {
|
||||
setValues((v) => ({ ...v, [k]: e.target.value }))
|
||||
// setRaw takes the next value directly (rich editor onChange), set adapts a
|
||||
// DOM change event onto it.
|
||||
const setRaw = (k) => (val) => {
|
||||
setValues((v) => ({ ...v, [k]: val }))
|
||||
setSaved(false)
|
||||
}
|
||||
const set = (k) => (e) => setRaw(k)(e.target.value)
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
@@ -77,10 +93,18 @@ export default function SettingsAdmin() {
|
||||
return (
|
||||
<section style={{ maxWidth: 620 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{FIELDS.map((f) => (
|
||||
<label key={f.key} style={{ display: 'block' }}>
|
||||
{FIELDS.map((f) => {
|
||||
// A rich field can't live inside a <label> (nested toolbar buttons +
|
||||
// contenteditable), so it uses a plain <div> wrapper instead.
|
||||
const Wrap = f.rich ? 'div' : 'label'
|
||||
return (
|
||||
<Wrap key={f.key} style={{ display: 'block' }}>
|
||||
<span className="field-label">{f.label}</span>
|
||||
{f.options ? (
|
||||
{f.rich ? (
|
||||
<Suspense fallback={<span className="spin" />}>
|
||||
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
|
||||
</Suspense>
|
||||
) : f.options ? (
|
||||
<select value={values[f.key]} onChange={set(f.key)} className="select">
|
||||
{f.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@@ -98,8 +122,9 @@ export default function SettingsAdmin() {
|
||||
{f.help}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</Wrap>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save changes'}
|
||||
@@ -111,6 +136,8 @@ export default function SettingsAdmin() {
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmailDelivery />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||
import { describe, kindLabel } from '../../../lib/shardEvents.js'
|
||||
import { ago } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||||
// cheat detection and login attempts that the public channel never carries.
|
||||
function AdminLiveFeed() {
|
||||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||||
{events.map((e) => (
|
||||
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||||
// stored encrypted, never returned — same convention as the Discord bot token.
|
||||
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||||
// effect immediately with no redeploy.
|
||||
|
||||
function Toggle({ checked, onChange, label }) {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
connected: '#7fd0a4',
|
||||
reconnecting: '#e0b070',
|
||||
error: '#d98b84',
|
||||
disconnected: 'var(--muted)',
|
||||
}
|
||||
|
||||
function StatusPanel({ config }) {
|
||||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||
const ingest = config.ingest || {}
|
||||
const health = config.health || {}
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||
{config.status || 'disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
{config.statusDetail && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||
)}
|
||||
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||||
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||||
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||||
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||||
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||||
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||||
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Town crier ──────────────────────────────────────────────────────────────
|
||||
function TownCrier() {
|
||||
const [id, setId] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [durationSec, setDurationSec] = useState(3600)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function post() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
if (!id.trim() || lines.length === 0) {
|
||||
setBusy(false)
|
||||
return setError('An id and at least one line are required.')
|
||||
}
|
||||
try {
|
||||
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
|
||||
setMsg(`Posted “${id.trim()}”.`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not post.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!id.trim()) return setError('Enter the id to remove.')
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.deleteTownCrier(id.trim())
|
||||
setMsg(`Removed “${id.trim()}”.`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
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)' }}>Town crier</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Message id</span>
|
||||
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Lines (one per line)</span>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Duration (seconds)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||||
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardAdmin() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [protocol, setProtocol] = useState(1)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const pollRef = useRef(null)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const c = await api.admin.getUoLinkConfig()
|
||||
setConfig(c)
|
||||
// Seed the editable fields once; later polls only refresh the status panel
|
||||
// so they never clobber what the admin is mid-typing.
|
||||
if (!initializedRef.current) {
|
||||
setBaseUrl(c.baseUrl || '')
|
||||
setWsUrl(c.wsUrl || '')
|
||||
setProtocol(c.protocol || 1)
|
||||
setEnabled(c.enabled)
|
||||
initializedRef.current = true
|
||||
}
|
||||
} catch {
|
||||
setError('Could not load uo-link config.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
pollRef.current = setInterval(load, 5000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [load])
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setMsg(''); setSaveError('')
|
||||
try {
|
||||
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
|
||||
if (token) body.token = token
|
||||
const saved = await api.admin.saveUoLinkConfig(body)
|
||||
setConfig(saved)
|
||||
setToken('')
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
setSaveError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!config) return <Loading />
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||||
|
||||
<StatusPanel config={config} />
|
||||
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Base URL (REST)</span>
|
||||
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">WebSocket URL (feed)</span>
|
||||
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Auth token</span>
|
||||
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||
<span className="field-label">Protocol</span>
|
||||
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||
</div>
|
||||
|
||||
<TownCrier />
|
||||
|
||||
<AdminLiveFeed />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
@@ -313,7 +311,7 @@ function Note({ msg, error }) {
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
export default function PlayerAccount() {
|
||||
const { logout, refresh } = useAuth()
|
||||
const { refresh } = useAuth()
|
||||
const [account, setAccount] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -336,40 +334,21 @@ export default function PlayerAccount() {
|
||||
}, [load, refresh])
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<MoonDot size={12} glow={0.5} />
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
|
||||
My Account
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||
← Site
|
||||
</Link>
|
||||
<button onClick={logout} className="pill">Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!loading && !error && account && (
|
||||
<>
|
||||
<div style={{ paddingTop: 24 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
<LinkedAccounts />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<div>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!loading && !error && account && (
|
||||
<>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
<LinkedAccounts />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
29
client/src/routes/player/PlayerCharacter.jsx
Normal file
29
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// A player's character sheet inside the portal. Owner-checked: the endpoint only
|
||||
// returns a sheet for a character on an account linked to the caller.
|
||||
export default function PlayerCharacter() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
|
||||
const restarting = error && error.status === 503
|
||||
const forbidden = error && error.status === 403
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to characters
|
||||
</Link>
|
||||
</p>
|
||||
{loading && <Loading />}
|
||||
{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} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
client/src/routes/player/PlayerCharacters.jsx
Normal file
15
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
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.
|
||||
export default function PlayerCharacters() {
|
||||
return (
|
||||
<div>
|
||||
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||
<VendorSales fetchSales={api.player.shard.sales} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function PlayerLogin() {
|
||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dest = location.state?.from?.pathname || '/account'
|
||||
const dest = location.state?.from?.pathname || '/player'
|
||||
// A staff member who signs in here belongs in the admin shell, not the portal.
|
||||
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
|
||||
|
||||
|
||||
160
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
160
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.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
|
||||
// experiences read as one app — the portal just carries fewer nav rows.
|
||||
|
||||
// Small inline stroke icons (16px, currentColor) — same frame as AdminLayout.
|
||||
function Icon({ children, size = 16 }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
|
||||
const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account', label: 'Account', icon: IconGear },
|
||||
]
|
||||
|
||||
// The sticky content header mirrors the active page. Character sheets live under
|
||||
// /player/char/:serial and keep their own in-page back link.
|
||||
const TITLES = {
|
||||
'/player': 'Characters',
|
||||
'/account': 'Account',
|
||||
}
|
||||
|
||||
const navBtnBase = {
|
||||
textAlign: 'left',
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
fontFamily: 'var(--sans)',
|
||||
fontSize: '0.92rem',
|
||||
textDecoration: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
transition: 'background .15s,color .15s',
|
||||
}
|
||||
|
||||
export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
TITLES[location.pathname] ||
|
||||
(location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
|
||||
|
||||
async function signOut() {
|
||||
await logout()
|
||||
navigate('/account/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-grid">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
style={{
|
||||
borderRight: '1px solid var(--line)',
|
||||
background: 'var(--bg)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
UOMysticmoon
|
||||
</div>
|
||||
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||
Player Portal
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{NAV.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className="admin-nav-link"
|
||||
style={({ isActive }) => ({
|
||||
...navBtnBase,
|
||||
background: isActive ? 'var(--blue)' : 'transparent',
|
||||
color: isActive ? 'var(--ink)' : 'var(--muted)',
|
||||
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||
})}
|
||||
>
|
||||
<n.icon />
|
||||
<span>{n.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', background: 'var(--mode-live)', boxShadow: '0 0 8px var(--mode-live)' }} />
|
||||
Signed in as <strong style={{ color: 'var(--ink)' }}>{user?.username}</strong>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="sans"
|
||||
style={{ display: 'block', width: '100%', textAlign: 'center', border: '1px solid var(--line)', borderRadius: 8, padding: 9, color: 'var(--muted)', background: 'transparent', fontSize: '0.84rem', cursor: 'pointer' }}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '20px 32px',
|
||||
borderBottom: '1px solid var(--line-soft)',
|
||||
background: 'var(--bg)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h1>
|
||||
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
|
||||
← Site
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default function PlayerRegister() {
|
||||
const [providers, setProviders] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role === 'player') navigate('/account', { replace: true })
|
||||
if (user && user.role === 'player') navigate('/player', { replace: true })
|
||||
}, [user, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +51,7 @@ export default function PlayerRegister() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||
navigate('/account', { replace: true })
|
||||
navigate('/player', { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken.')
|
||||
else if (err.status === 403) setError('Registration is not open right now.')
|
||||
|
||||
56
client/src/routes/public/CmsPage.jsx
Normal file
56
client/src/routes/public/CmsPage.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import '../../blocks/index.js' // registers all block types
|
||||
import { BlockList } from '../../blocks/BlockRenderer.jsx'
|
||||
|
||||
// Renders a CMS page composed of blocks. Two modes:
|
||||
// - live: /:slug → fetches the published page (staff see drafts)
|
||||
// - preview: /preview/:id/:token → fetches the current state via a token,
|
||||
// regardless of publish status (draft-preview links).
|
||||
export default function CmsPage({ preview = false }) {
|
||||
const params = useParams()
|
||||
const { loading, error, data: page } = useAsync(
|
||||
() => (preview ? api.pagePreview(params.id, params.token) : api.page(params.slug)),
|
||||
[preview, params.id, params.token, params.slug],
|
||||
)
|
||||
|
||||
// Reflect the page's title + meta description while it's mounted, then restore.
|
||||
useEffect(() => {
|
||||
if (!page) return
|
||||
const prevTitle = document.title
|
||||
document.title = page.metadata?.seoTitle || page.title || prevTitle
|
||||
return () => {
|
||||
document.title = prevTitle
|
||||
}
|
||||
}, [page])
|
||||
|
||||
const layout = page?.settings?.layout || 'default'
|
||||
const widthClass = layout === 'full_width' || layout === 'landing' ? 'shell-wide' : 'shell'
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className={`${widthClass} page-body`} style={{ paddingTop: 40 }}>
|
||||
{preview && page && (
|
||||
<div className="page-preview-banner sans">
|
||||
Preview — this is the current draft state and isn’t publicly visible.
|
||||
</div>
|
||||
)}
|
||||
{loading && <Loading />}
|
||||
{error && (
|
||||
<ErrorState
|
||||
message={error.status === 404 ? 'That page could not be found.' : 'Could not load this page.'}
|
||||
/>
|
||||
)}
|
||||
{page && (
|
||||
<article className={`page-blocks page-layout--${layout}`}>
|
||||
<BlockList blocks={page.blocks} />
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function Portal() {
|
||||
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||
|
||||
return (
|
||||
<PublicLayout header={false}>
|
||||
<PublicLayout>
|
||||
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{PREVIEW && draft && (
|
||||
<div
|
||||
|
||||
222
client/src/routes/public/Shard.jsx
Normal file
222
client/src/routes/public/Shard.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||
function Sparkline({ series }) {
|
||||
if (!series || series.length < 2) return null
|
||||
const w = 320
|
||||
const h = 56
|
||||
const golds = series.map((s) => Number(s.gold) || 0)
|
||||
const min = Math.min(...golds)
|
||||
const max = Math.max(...golds)
|
||||
const span = max - min || 1
|
||||
const pts = series
|
||||
.map((s, i) => {
|
||||
const x = (i / (series.length - 1)) * w
|
||||
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||
function Stat({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Shard() {
|
||||
const { loading, error, data } = useAsync(() =>
|
||||
Promise.all([
|
||||
api.shard.status(),
|
||||
api.shard.idoc(),
|
||||
api.shard.economy(60),
|
||||
api.shard.online(),
|
||||
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||
)
|
||||
const { events, connected } = useShardFeed({ max: 30 })
|
||||
|
||||
const status = data?.status
|
||||
const online = status?.pluginConnected
|
||||
const gold = status?.economy?.gold
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load shard data right now." />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{/* Connection banner */}
|
||||
<section
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '24px 26px',
|
||||
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||
borderRadius: 10,
|
||||
background: online
|
||||
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||
{online ? 'The shard is online' : 'The shard is offline'}
|
||||
</strong>
|
||||
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||
{online
|
||||
? 'The gate to Britannia stands open.'
|
||||
: status?.enabled
|
||||
? 'The link to the game world is down — checking back automatically.'
|
||||
: 'Live shard data is not configured yet.'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
|
||||
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||
</section>
|
||||
|
||||
{/* 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 }}>
|
||||
Staff online
|
||||
</div>
|
||||
{(!data.online || data.online.length === 0) ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.online.map((p) => (
|
||||
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||
{p.name || p.serial}
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Economy sparkline */}
|
||||
{data.economy && data.economy.length > 1 && (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Gold supply over time
|
||||
</div>
|
||||
<Sparkline series={data.economy} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{/* Latest IDOC */}
|
||||
<FeedList
|
||||
title="Houses in danger (IDOC)"
|
||||
empty="No houses are collapsing right now."
|
||||
items={data.idoc.map((h) => ({
|
||||
id: h.serial,
|
||||
text: `${h.name || 'A house'}${h.region ? ` — ${h.region}` : ''}`,
|
||||
when: h.updatedAt,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live ticker */}
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
Live feed
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||
View all activity →
|
||||
</Link>
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
Waiting for something to happen in the world…
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{events.map((ev) => (
|
||||
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedList({ title, items, empty }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
{title}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
81
client/src/routes/public/ShardActivity.jsx
Normal file
81
client/src/routes/public/ShardActivity.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
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 { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Public activity feed: the full shard event log, filterable by category, with a
|
||||
// live tail that prepends new events as they happen.
|
||||
export default function ShardActivity() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||
const { events: live } = useShardFeed({ max: 60 })
|
||||
const [cat, setCat] = useState('all')
|
||||
|
||||
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||
const merged = useMemo(() => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const e of [...live, ...(data || [])]) {
|
||||
const key = `${e.kind}-${e.t}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(e)
|
||||
}
|
||||
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||
}, [live, data])
|
||||
|
||||
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||
</p>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setCat(c.id)}
|
||||
className="pill"
|
||||
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
filtered.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filtered.map((e) => (
|
||||
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||
{kindLabel(e.kind)}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -79,6 +79,10 @@ a {
|
||||
width: min(760px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
.shell-wide {
|
||||
width: min(1280px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -542,6 +546,20 @@ button[disabled] {
|
||||
}
|
||||
|
||||
/* ===== Hero canvas editor ===== */
|
||||
/* Rich-text hero line (e.g. the homepage teaser). Inherits the line's font/color
|
||||
from its inline style; collapse the editor's outer block margins so spacing is
|
||||
driven by the line's own marginTop rather than a nested <p>. */
|
||||
.hero-rich > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.hero-rich > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hero-rich a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hero-el-editable {
|
||||
outline: 1px dashed rgba(127, 153, 189, 0.45);
|
||||
outline-offset: 2px;
|
||||
@@ -688,6 +706,47 @@ button[disabled] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
/* Admin sidebar — collapsible category sections */
|
||||
.admin-nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.admin-nav-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 14px 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--dim);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.admin-nav-head:hover {
|
||||
color: var(--muted);
|
||||
}
|
||||
.admin-nav-chev {
|
||||
transition: transform 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.admin-nav-items {
|
||||
padding-left: 6px;
|
||||
}
|
||||
.admin-nav-link > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-nav-link svg {
|
||||
flex: 0 0 16px;
|
||||
}
|
||||
.wiki-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 230px 1fr;
|
||||
@@ -699,3 +758,265 @@ button[disabled] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== CMS page builder — public block rendering ===== */
|
||||
/* Vertical rhythm between top-level blocks on a rendered page. */
|
||||
.page-blocks > * + * {
|
||||
margin-top: 26px;
|
||||
}
|
||||
.page-heading {
|
||||
font-family: var(--serif, Georgia, serif);
|
||||
color: var(--ink);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.page-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 8px 0;
|
||||
}
|
||||
/* image block */
|
||||
.page-image {
|
||||
margin: 0;
|
||||
}
|
||||
.page-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
}
|
||||
.page-image figcaption {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.page-image--center {
|
||||
text-align: center;
|
||||
}
|
||||
.page-image--center img,
|
||||
.page-image--center figcaption {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.page-image--right {
|
||||
text-align: right;
|
||||
}
|
||||
.page-image--right img,
|
||||
.page-image--right figcaption {
|
||||
margin-left: auto;
|
||||
}
|
||||
.page-image--full img {
|
||||
width: 100%;
|
||||
}
|
||||
/* cta block */
|
||||
.page-cta-wrap {
|
||||
display: flex;
|
||||
}
|
||||
.page-cta--secondary {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* quote block */
|
||||
.page-quote {
|
||||
margin: 0;
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 4px 0 4px 20px;
|
||||
}
|
||||
.page-quote blockquote {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
font-style: italic;
|
||||
}
|
||||
.page-quote figcaption {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
/* two-column block */
|
||||
.page-two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
.page-column > * + * {
|
||||
margin-top: 18px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page-two-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== CMS page builder — column sub-block editor ===== */
|
||||
.pb-two-column-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.pb-two-column-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.pb-column-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
.pb-column-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pb-add-select {
|
||||
width: auto;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pb-subblock {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.pb-subblock-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pb-subblock-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.pb-mini {
|
||||
min-width: 28px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ===== CMS page builder — admin canvas ===== */
|
||||
.pb-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
padding: 10px 0;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.pb-error {
|
||||
border: 1px solid #6e3b38;
|
||||
background: rgba(110, 59, 56, 0.16);
|
||||
color: #e6a9a3;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.pb-notice {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.pb-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.pb-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.9rem;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pb-tab.is-active {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.pb-palette {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pb-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.pb-block-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
.pb-block-card.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.pb-block-card.is-hidden {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.pb-block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.pb-drag {
|
||||
cursor: grab;
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
}
|
||||
.pb-block-body {
|
||||
padding: 14px;
|
||||
}
|
||||
.pb-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.pb-danger {
|
||||
border-color: #6e3b38;
|
||||
color: #d98b84;
|
||||
}
|
||||
.pb-danger:hover:not([disabled]) {
|
||||
background: rgba(110, 59, 56, 0.18);
|
||||
border-color: #8a4b47;
|
||||
}
|
||||
|
||||
/* Draft-preview banner on the public renderer. */
|
||||
.page-preview-banner {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -70,11 +70,10 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
CONTACT_TO=UOMysticmoon@gmail.com
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
|
||||
# It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. The contact recipient is the `contact_email` site
|
||||
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
||||
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
@@ -89,3 +88,11 @@ CLIENT_ORIGIN=http://localhost:5173
|
||||
# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above).
|
||||
BOT_INTERNAL_URL=http://localhost:4100
|
||||
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||
|
||||
# News announcement pipeline (published news post -> in-game town crier + Discord
|
||||
# #news). The dispatcher is an in-process poller; these tune it. Links in the
|
||||
# announcements use APP_BASE_URL (set above), so set that in production too.
|
||||
# ANNOUNCE_POLL_MS how often the dispatcher sweeps for due/retry legs
|
||||
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
|
||||
ANNOUNCE_POLL_MS=15000
|
||||
TOWNCRIER_DURATION_SEC=3600
|
||||
|
||||
@@ -236,6 +236,153 @@ CREATE TABLE IF NOT EXISTS bot_config (
|
||||
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
|
||||
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
|
||||
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
|
||||
-- they are read live from the `google` auth_providers row. The refresh token is
|
||||
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
|
||||
-- admin API (never returned; responses expose only hasRefreshToken).
|
||||
CREATE TABLE IF NOT EXISTS email_config (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
|
||||
sender_name VARCHAR(120) NULL, -- optional From display name
|
||||
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
last_verified_at DATETIME NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_email_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── uo-link sidecar ────────────────────────────────────────────────────────
|
||||
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
|
||||
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
|
||||
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
|
||||
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
|
||||
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
|
||||
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
|
||||
-- in production the sidecar runs on a different host from the website. `status`/
|
||||
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
|
||||
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
|
||||
-- so a shard restart can be detected (and caches dropped).
|
||||
CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) NULL,
|
||||
auth_token_enc TEXT NULL,
|
||||
protocol INT NOT NULL DEFAULT 1,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
plugin_connected TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_event_at DATETIME NULL,
|
||||
boot_id VARCHAR(64) NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Append-only log of notable shard events ingested from the uo-link WebSocket
|
||||
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
|
||||
-- client writes here, and the public/admin read endpoints + live feeds read from
|
||||
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
|
||||
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
||||
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
||||
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
||||
-- dedupe_key = sha1(kind + t + stable-json(payload)); with the UNIQUE index it
|
||||
-- makes INSERT IGNORE idempotent so WS-reconnect backfill never double-inserts.
|
||||
CREATE TABLE IF NOT EXISTS shard_events (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(48) NOT NULL,
|
||||
t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar)
|
||||
boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId)
|
||||
payload JSON NOT NULL, -- the full event object
|
||||
dedupe_key CHAR(40) NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_events_kind_t (kind, t),
|
||||
INDEX idx_shard_events_t (t)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
|
||||
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
|
||||
-- server.hello.bootId). web_id is the linked website user id (present when the
|
||||
-- account is linked), so the roster can be correlated to site accounts.
|
||||
CREATE TABLE IF NOT EXISTS shard_online (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key)
|
||||
name VARCHAR(120) NULL,
|
||||
acct VARCHAR(120) NULL,
|
||||
web_id INT NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
hits INT NULL,
|
||||
hits_max INT NULL,
|
||||
mana INT NULL,
|
||||
mana_max INT NULL,
|
||||
stam INT NULL,
|
||||
stam_max INT NULL,
|
||||
str INT NULL,
|
||||
dex INT NULL,
|
||||
`int` INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_online_acct (acct)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Total-gold-supply time series (from the periodic economy.supply event). Kept
|
||||
-- append-only so the public status page can render a supply-over-time sparkline.
|
||||
CREATE TABLE IF NOT EXISTS shard_economy (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
accounts INT NULL, -- number of accounts included in the total
|
||||
gold BIGINT NULL, -- total gold supply across all accounts
|
||||
t BIGINT NOT NULL, -- sample time, epoch ms
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_economy_t (t)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
|
||||
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
|
||||
-- indexed lookup rather than a scan.
|
||||
CREATE TABLE IF NOT EXISTS shard_houses (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY,
|
||||
stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
region VARCHAR(120) NULL,
|
||||
name VARCHAR(160) NULL,
|
||||
owner_serial VARCHAR(20) NULL,
|
||||
owner_acct VARCHAR(120) NULL,
|
||||
built_on DATETIME NULL,
|
||||
last_refreshed DATETIME NULL,
|
||||
is_idoc TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_houses_idoc (is_idoc)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Site-side mirror of in-game-account → website-user links. The sidecar is the
|
||||
-- source of truth (it tags the game account with the websiteUserId on
|
||||
-- /link/confirm); this table mirrors it so the player portal can list a user's
|
||||
-- linked accounts and enforce ownership on roster/vendor reads without a shard
|
||||
-- round-trip. account is unique (one game account maps to at most one site user);
|
||||
-- a single user may link several game accounts.
|
||||
CREATE TABLE IF NOT EXISTS shard_account_links (
|
||||
account VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
char_name VARCHAR(120) NULL,
|
||||
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_shard_links_user (user_id)
|
||||
) 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
|
||||
@@ -463,6 +610,74 @@ CREATE TABLE IF NOT EXISTS mod_notes (
|
||||
INDEX idx_mod_notes_user (discord_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Generic CMS pages composed from a fixed palette of blocks (the page builder).
|
||||
-- `blocks` is a JSON array of block-envelope objects ({ id, type, version,
|
||||
-- visible, props }); it is stored as text and parsed/validated in app code
|
||||
-- against the block registry (server/src/blocks) on every save — the same
|
||||
-- pattern role_menus.mapping uses, since MariaDB's JSON type is just LONGTEXT and
|
||||
-- the driver hands it back as a string anyway. The seo_*/og_image/canonical_url/
|
||||
-- robots and layout/nav_* columns are metadata/settings surfaced grouped in the
|
||||
-- API response; several have no consumer yet but are cheap to add now and painful
|
||||
-- to retrofit once real pages exist. published_at mirrors posts: stamped the first
|
||||
-- time a page goes to 'published'.
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(160) NOT NULL UNIQUE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
blocks MEDIUMTEXT NOT NULL, -- JSON array of block objects
|
||||
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
|
||||
protected TINYINT(1) NOT NULL DEFAULT 0,
|
||||
author_id INT NULL,
|
||||
-- SEO / social metadata (grouped under `metadata` in the API response).
|
||||
seo_title VARCHAR(200) NULL,
|
||||
meta_description VARCHAR(400) NULL,
|
||||
og_image VARCHAR(500) NULL,
|
||||
canonical_url VARCHAR(500) NULL,
|
||||
robots VARCHAR(100) NULL,
|
||||
-- Presentation / navigation (grouped under `settings` in the API response).
|
||||
layout ENUM('default','full_width','landing') NOT NULL DEFAULT 'default',
|
||||
show_in_nav TINYINT(1) NOT NULL DEFAULT 0,
|
||||
nav_group ENUM('main','footer','account','hidden') NULL,
|
||||
nav_order INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
published_at DATETIME NULL,
|
||||
CONSTRAINT fk_pages_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_pages_status (status),
|
||||
INDEX idx_pages_nav (show_in_nav, nav_group, nav_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Announcement pipeline. One row per publish event of a news post; the table
|
||||
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js
|
||||
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
|
||||
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
|
||||
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
|
||||
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
|
||||
-- own attempt count, last error, and next-due time for exponential backoff.
|
||||
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
|
||||
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
|
||||
CREATE TABLE IF NOT EXISTS announce_jobs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',
|
||||
|
||||
towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
towncrier_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
towncrier_last_error TEXT NULL,
|
||||
towncrier_next_attempt_at DATETIME NULL,
|
||||
|
||||
discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
discord_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
discord_last_error TEXT NULL,
|
||||
discord_next_attempt_at DATETIME NULL,
|
||||
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
|
||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
@@ -499,3 +714,13 @@ ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DE
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
|
||||
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);
|
||||
|
||||
-- News → town-crier + Discord announcement pipeline. Add the announcement-state
|
||||
-- columns to posts on databases created before the pipeline landed. announced_at
|
||||
-- is stamped once both legs deliver; announce_job_id points at the announce_jobs
|
||||
-- row for the post's admin status panel. Kept as a plain column (not a hard FK)
|
||||
-- so the idempotent boot migration never trips over a re-added constraint — the
|
||||
-- pointer is resolved in application code and the CASCADE on announce_jobs.post_id
|
||||
-- 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;
|
||||
|
||||
24
server/package-lock.json
generated
24
server/package-lock.json
generated
@@ -26,7 +26,8 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.17.5",
|
||||
"speakeasy": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4",
|
||||
@@ -2386,6 +2387,27 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.17.5",
|
||||
"speakeasy": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4",
|
||||
|
||||
@@ -66,6 +66,20 @@ function verifyTotpChallenge(token) {
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Short-lived, unguessable link token for previewing a (possibly unpublished)
|
||||
// CMS page. Carries purpose:'page_preview' + the page id and nothing else; it is
|
||||
// NOT a session (session validation rejects it) and only grants read of that one
|
||||
// page's current block state. Default 1h expiry per the page-builder spec.
|
||||
function signPagePreview(pageId, { expiresIn = '1h' } = {}) {
|
||||
return jwt.sign({ pageId, purpose: 'page_preview' }, JWT_SECRET, { expiresIn })
|
||||
}
|
||||
|
||||
function verifyPagePreview(token) {
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded || decoded.purpose !== 'page_preview') return null
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
|
||||
function cookieMaxAge() {
|
||||
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
|
||||
@@ -122,6 +136,8 @@ module.exports = {
|
||||
verifyToken,
|
||||
signTotpChallenge,
|
||||
verifyTotpChallenge,
|
||||
signPagePreview,
|
||||
verifyPagePreview,
|
||||
cookieMaxAge,
|
||||
cookieSecure,
|
||||
cookieOptions,
|
||||
|
||||
30
server/src/blocks/index.js
Normal file
30
server/src/blocks/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Block registry entrypoint. Requiring this module registers every server-side
|
||||
// block definition (schema + cache policy) exactly once, then re-exports the
|
||||
// registry API and the blocks validator. Anything that needs to validate a
|
||||
// page's blocks or look up a block type should require THIS module, not
|
||||
// ./registry directly, so the definitions are guaranteed to be loaded.
|
||||
//
|
||||
// Wave 1 block definitions are registered below, one require() per block (each
|
||||
// module self-registers on load). Requiring THIS module guarantees they are all
|
||||
// present before anything validates a page's blocks.
|
||||
|
||||
const registry = require('./registry')
|
||||
const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks')
|
||||
const { sanitizeBlocks } = require('./sanitizeBlocks')
|
||||
|
||||
// ── Wave 1 block definitions (self-register on require) ────────────────
|
||||
require('./types/heading')
|
||||
require('./types/richText')
|
||||
require('./types/image')
|
||||
require('./types/twoColumn')
|
||||
require('./types/cta')
|
||||
require('./types/divider')
|
||||
require('./types/quote')
|
||||
|
||||
module.exports = {
|
||||
...registry,
|
||||
validateBlocks,
|
||||
sanitizeBlocks,
|
||||
MAX_BLOCKS,
|
||||
MAX_SUBBLOCKS,
|
||||
}
|
||||
84
server/src/blocks/propHelpers.js
Normal file
84
server/src/blocks/propHelpers.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Small shared validators used by the Wave 1 block schemas. Each block's schema
|
||||
// composes these and returns a flat array of error strings; validateBlocks
|
||||
// prefixes each with the block path (so 'text is required' becomes
|
||||
// 'blocks[2].props.text is required'). Phrase messages to read well after that
|
||||
// prefix — start with the prop name.
|
||||
|
||||
/** @returns {boolean} true if v is a non-empty (after trim) string. */
|
||||
function isNonEmptyString(v) {
|
||||
return typeof v === 'string' && v.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a same-origin relative URL ("/uploads/x.png", "/wiki/foo") or an
|
||||
* absolute http/https URL. Rejects javascript:, data:, protocol-relative
|
||||
* ("//evil"), and anything else — the block renderers drop these into hrefs/src
|
||||
* so this is a security boundary, not just a format check.
|
||||
* @param {unknown} v
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSafeUrl(v) {
|
||||
if (typeof v !== 'string' || v.trim() === '') return false
|
||||
const s = v.trim()
|
||||
if (s.startsWith('//')) return false // protocol-relative — ambiguous origin
|
||||
if (s.startsWith('/')) return true // same-origin relative
|
||||
try {
|
||||
const u = new URL(s)
|
||||
return u.protocol === 'http:' || u.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an enum validator for a prop.
|
||||
* @param {string} name prop name (for the message)
|
||||
* @param {string[]} allowed
|
||||
* @returns {(v: unknown) => string|null} error string or null
|
||||
*/
|
||||
function oneOf(name, allowed) {
|
||||
return (v) => (allowed.includes(v) ? null : `${name} must be one of ${allowed.join(', ')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a required text prop: present, non-empty, within maxLen.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function requiredText(name, v, maxLen) {
|
||||
if (!isNonEmptyString(v)) return `${name} is required`
|
||||
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an optional text prop: if present it must be a string within maxLen.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function optionalText(name, v, maxLen) {
|
||||
if (v === undefined || v === null || v === '') return null
|
||||
if (typeof v !== 'string') return `${name} must be a string`
|
||||
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any prop key not in `allowed`. Keeps a block's props tight so nothing
|
||||
* unexpected is smuggled through and stored.
|
||||
* @returns {string[]} error strings
|
||||
*/
|
||||
function onlyKeys(props, allowed) {
|
||||
const errors = []
|
||||
for (const key of Object.keys(props)) {
|
||||
if (!allowed.includes(key)) errors.push(`${key} is not an allowed prop`)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isNonEmptyString,
|
||||
isSafeUrl,
|
||||
oneOf,
|
||||
requiredText,
|
||||
optionalText,
|
||||
onlyKeys,
|
||||
}
|
||||
103
server/src/blocks/registry.js
Normal file
103
server/src/blocks/registry.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Block registry (server side) — the single source of truth for what block
|
||||
// types exist, how their props validate, and how long a rendered block may be
|
||||
// cached. The admin builder UI, the public renderer, and this server-side
|
||||
// validation are all driven from a registry entry rather than a switch statement
|
||||
// scattered across files: adding a block later means adding ONE entry (here on
|
||||
// the server for schema/cache, and one in client/src/blocks for the React
|
||||
// renderer/editor), not editing four places.
|
||||
//
|
||||
// A registered definition looks like:
|
||||
// {
|
||||
// type: 'heading', // stable string id, unique across the registry
|
||||
// version: 1, // prop-schema version; bump when props change so a
|
||||
// // one-time migration can transform older blocks
|
||||
// schema: (props) => [], // returns an array of error strings ([] = valid)
|
||||
// sanitize: (props) => props, // optional normalizer run on save AFTER
|
||||
// // validation, e.g. rich_text runs its html through
|
||||
// // the shared allowlist; returns cleaned props
|
||||
// cacheTTL: null, // seconds a rendered instance may be cached;
|
||||
// // null = never cache (static blocks). Dynamic
|
||||
// // Wave 2 blocks set this (e.g. server_status: 10).
|
||||
// container: false, // true only for block types that hold sub-blocks
|
||||
// containerSlots: [], // prop keys holding sub-block arrays, e.g.
|
||||
// // ['left','right'] for two_column
|
||||
// }
|
||||
//
|
||||
// This module is intentionally empty of block types — it only defines the
|
||||
// pattern. Wave 1 block definitions register themselves via ./index.js.
|
||||
|
||||
// The only keys allowed at the top level of a stored block object. Everything
|
||||
// block-specific lives inside `props`; nothing else lives at the top level.
|
||||
// Ordering is the array position, not a stored field — so a reorder is just a
|
||||
// reorder of the array, and `id` is never derived from position.
|
||||
const RESERVED_KEYS = Object.freeze(['id', 'type', 'version', 'visible', 'props'])
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
/**
|
||||
* Register a block definition. Throws on a missing type or a duplicate — both
|
||||
* are programmer errors surfaced at boot, not runtime input.
|
||||
* @param {object} def
|
||||
* @returns {object} the normalized, frozen definition
|
||||
*/
|
||||
function registerBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
|
||||
throw new Error('registerBlock: a block definition needs a string `type`')
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
if (def.schema != null && typeof def.schema !== 'function') {
|
||||
throw new Error(`registerBlock: ${def.type}.schema must be a function`)
|
||||
}
|
||||
if (def.sanitize != null && typeof def.sanitize !== 'function') {
|
||||
throw new Error(`registerBlock: ${def.type}.sanitize must be a function`)
|
||||
}
|
||||
const containerSlots = def.containerSlots || []
|
||||
if (def.container && containerSlots.length === 0) {
|
||||
throw new Error(`registerBlock: container block ${def.type} needs containerSlots`)
|
||||
}
|
||||
const entry = Object.freeze({
|
||||
type: def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
schema: def.schema || null,
|
||||
sanitize: def.sanitize || null,
|
||||
cacheTTL: def.cacheTTL == null ? null : Number(def.cacheTTL),
|
||||
container: Boolean(def.container),
|
||||
containerSlots: Object.freeze([...containerSlots]),
|
||||
})
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
function getBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `type` is a registered block. */
|
||||
function hasBlock(type) {
|
||||
return registry.has(type)
|
||||
}
|
||||
|
||||
/** @returns {object[]} all registered definitions (registration order). */
|
||||
function listBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every registered block. Test-only — lets a suite register a fixture set
|
||||
* and start from a known-empty registry.
|
||||
*/
|
||||
function _resetRegistry() {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVED_KEYS,
|
||||
registerBlock,
|
||||
getBlock,
|
||||
hasBlock,
|
||||
listBlocks,
|
||||
_resetRegistry,
|
||||
}
|
||||
47
server/src/blocks/sanitizeBlocks.js
Normal file
47
server/src/blocks/sanitizeBlocks.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Normalize + sanitize a validated blocks array before persisting. Runs AFTER
|
||||
// validateBlocks (which guarantees the envelope/prop shape), so this can assume
|
||||
// well-formed input and focus on: applying each block's registry `sanitize`
|
||||
// normalizer (e.g. rich_text runs its html through the allowlist), stamping the
|
||||
// registry `version`, defaulting `visible` to true, and recursing one level into
|
||||
// container slots. Returns a new array; never mutates the input.
|
||||
|
||||
const { getBlock } = require('./registry')
|
||||
|
||||
function sanitizeBlocks(blocks) {
|
||||
if (!Array.isArray(blocks)) return []
|
||||
return blocks.map(sanitizeOne)
|
||||
}
|
||||
|
||||
function sanitizeOne(block) {
|
||||
const def = getBlock(block.type)
|
||||
if (!def) return block // unreachable after validation, but stay defensive
|
||||
|
||||
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
|
||||
|
||||
// Recurse into container slots first (leaf sub-blocks get sanitized too).
|
||||
if (def.container) {
|
||||
for (const slot of def.containerSlots) {
|
||||
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the block's own normalizer last (operates on its scalar props).
|
||||
if (def.sanitize) {
|
||||
try {
|
||||
props = def.sanitize(props)
|
||||
} catch {
|
||||
// Leave props as-is; validation already passed, a sanitize throw shouldn't
|
||||
// block the save.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
version: Number.isInteger(block.version) ? block.version : def.version,
|
||||
visible: block.visible !== false,
|
||||
props,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sanitizeBlocks }
|
||||
22
server/src/blocks/types/cta.js
Normal file
22
server/src/blocks/types/cta.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// cta — a call-to-action button. `text` is the label, `url` the destination
|
||||
// (same-origin path or http/https), `style` picks primary/secondary appearance.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { isSafeUrl, oneOf, requiredText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const STYLES = ['primary', 'secondary']
|
||||
const MAX_TEXT = 100
|
||||
|
||||
registerBlock({
|
||||
type: 'cta',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['text', 'url', 'style'])
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
if (!isSafeUrl(props.url)) errors.push('url must be a same-origin path or http(s) URL')
|
||||
const style = oneOf('style', STYLES)(props.style)
|
||||
if (style) errors.push(style)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
12
server/src/blocks/types/divider.js
Normal file
12
server/src/blocks/types/divider.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// divider — a pure spacer / horizontal rule. Carries no props.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../propHelpers')
|
||||
|
||||
registerBlock({
|
||||
type: 'divider',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
return onlyKeys(props, [])
|
||||
},
|
||||
})
|
||||
21
server/src/blocks/types/heading.js
Normal file
21
server/src/blocks/types/heading.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// heading — a section heading. `level` picks the tag (h1–h4), `text` is plain
|
||||
// text (the renderer escapes it; no HTML here — use rich_text for markup).
|
||||
const { registerBlock } = require('../registry')
|
||||
const { oneOf, requiredText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const LEVELS = ['h1', 'h2', 'h3', 'h4']
|
||||
const MAX_TEXT = 200
|
||||
|
||||
registerBlock({
|
||||
type: 'heading',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['level', 'text'])
|
||||
const level = oneOf('level', LEVELS)(props.level)
|
||||
if (level) errors.push(level)
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
27
server/src/blocks/types/image.js
Normal file
27
server/src/blocks/types/image.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// image — a single image with optional caption. `src` must be a same-origin
|
||||
// upload path or an http/https URL (isSafeUrl); `alignment` controls layout.
|
||||
// Stays URL-based until the Wave 3 asset picker lands, then src swaps to an
|
||||
// asset id via a small migration.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { isSafeUrl, oneOf, optionalText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const ALIGNMENTS = ['left', 'center', 'right', 'full']
|
||||
const MAX_ALT = 300
|
||||
const MAX_CAPTION = 500
|
||||
|
||||
registerBlock({
|
||||
type: 'image',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['src', 'alt', 'caption', 'alignment'])
|
||||
if (!isSafeUrl(props.src)) errors.push('src must be a same-origin path or http(s) URL')
|
||||
const alt = optionalText('alt', props.alt, MAX_ALT)
|
||||
if (alt) errors.push(alt)
|
||||
const caption = optionalText('caption', props.caption, MAX_CAPTION)
|
||||
if (caption) errors.push(caption)
|
||||
const alignment = oneOf('alignment', ALIGNMENTS)(props.alignment)
|
||||
if (alignment) errors.push(alignment)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
20
server/src/blocks/types/quote.js
Normal file
20
server/src/blocks/types/quote.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// quote — a pull quote with optional attribution.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { requiredText, optionalText, onlyKeys } = require('../propHelpers')
|
||||
|
||||
const MAX_TEXT = 1000
|
||||
const MAX_ATTRIB = 200
|
||||
|
||||
registerBlock({
|
||||
type: 'quote',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['text', 'attribution'])
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
const attribution = optionalText('attribution', props.attribution, MAX_ATTRIB)
|
||||
if (attribution) errors.push(attribution)
|
||||
return errors
|
||||
},
|
||||
})
|
||||
26
server/src/blocks/types/richText.js
Normal file
26
server/src/blocks/types/richText.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// rich_text — a block of HTML authored in the shared rich-text editor. Validated
|
||||
// only for type/size here; the actual safety comes from `sanitize`, which runs
|
||||
// the html through the same allowlist (cleanBody) used for posts/wiki bodies, so
|
||||
// a direct API call can't smuggle unsafe markup past the editor.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../propHelpers')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
|
||||
const MAX_HTML = 50000
|
||||
|
||||
registerBlock({
|
||||
type: 'rich_text',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['html'])
|
||||
if (typeof props.html !== 'string') errors.push('html must be a string')
|
||||
else if (props.html.length > MAX_HTML) {
|
||||
errors.push(`html must be at most ${MAX_HTML} characters`)
|
||||
}
|
||||
return errors
|
||||
},
|
||||
sanitize(props) {
|
||||
return { ...props, html: cleanBody(props.html) }
|
||||
},
|
||||
})
|
||||
20
server/src/blocks/types/twoColumn.js
Normal file
20
server/src/blocks/types/twoColumn.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// two_column — the only container block. Holds two ordered arrays of sub-blocks
|
||||
// (`left`, `right`). The sub-block arrays are validated by validateBlocks, which
|
||||
// also enforces the one-level nesting cap (a column may not contain another
|
||||
// container). This schema only guards the prop shape; the slot arrays default to
|
||||
// empty when absent.
|
||||
const { registerBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../propHelpers')
|
||||
|
||||
registerBlock({
|
||||
type: 'two_column',
|
||||
version: 1,
|
||||
cacheTTL: null,
|
||||
container: true,
|
||||
containerSlots: ['left', 'right'],
|
||||
schema(props) {
|
||||
// Slot array contents are validated by validateBlocks' container handling;
|
||||
// here we only reject stray props.
|
||||
return onlyKeys(props, ['left', 'right'])
|
||||
},
|
||||
})
|
||||
119
server/src/blocks/validateBlocks.js
Normal file
119
server/src/blocks/validateBlocks.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// Server-side validation for a page's `blocks` array, run on every save before
|
||||
// persisting. The admin UI validates client-side too, but that can be bypassed
|
||||
// by a direct API call, so this is the authoritative gate: it enforces the block
|
||||
// envelope (reserved keys only), that every `type` is a registered block, that
|
||||
// each block's props satisfy the registry schema, and the one-level nesting cap
|
||||
// (only container blocks may hold sub-blocks, and sub-blocks may not themselves
|
||||
// be containers).
|
||||
//
|
||||
// Returns { valid, errors } — a flat list of human-readable error strings, each
|
||||
// prefixed with the path to the offending block (e.g. `blocks[2].props.text`).
|
||||
// It never throws on bad input; callers turn a non-empty `errors` into a 400.
|
||||
|
||||
const { getBlock, RESERVED_KEYS } = require('./registry')
|
||||
|
||||
// Bound the payload so a single page can't carry an unreasonable block tree.
|
||||
const MAX_BLOCKS = 100 // top-level blocks per page
|
||||
const MAX_SUBBLOCKS = 50 // sub-blocks per container slot
|
||||
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
|
||||
|
||||
/**
|
||||
* Validate a stored blocks array against the registry.
|
||||
* @param {unknown} blocks
|
||||
* @returns {{ valid: boolean, errors: string[] }}
|
||||
*/
|
||||
function validateBlocks(blocks) {
|
||||
const errors = []
|
||||
if (!Array.isArray(blocks)) {
|
||||
return { valid: false, errors: ['blocks must be an array'] }
|
||||
}
|
||||
if (blocks.length > MAX_BLOCKS) {
|
||||
errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`)
|
||||
}
|
||||
const seenIds = new Set()
|
||||
blocks.forEach((block, i) => {
|
||||
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
|
||||
})
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one block envelope in place. `nested` = true when validating a
|
||||
* sub-block inside a container slot, which forbids further nesting.
|
||||
*/
|
||||
function validateBlock(block, path, seenIds, errors, { nested }) {
|
||||
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
|
||||
errors.push(`${path} must be an object`)
|
||||
return
|
||||
}
|
||||
|
||||
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
||||
for (const key of Object.keys(block)) {
|
||||
if (!RESERVED_KEYS.includes(key)) {
|
||||
errors.push(`${path}.${key} is not an allowed top-level key`)
|
||||
}
|
||||
}
|
||||
|
||||
// id — stable, unique across the whole page (top-level and nested share one
|
||||
// namespace since ids are the future join point for revision history).
|
||||
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
|
||||
errors.push(`${path}.id must be a short id string`)
|
||||
} else if (seenIds.has(block.id)) {
|
||||
errors.push(`${path}.id duplicates another block id (${block.id})`)
|
||||
} else {
|
||||
seenIds.add(block.id)
|
||||
}
|
||||
|
||||
// visible — optional in input, but if present must be a boolean.
|
||||
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
|
||||
errors.push(`${path}.visible must be a boolean`)
|
||||
}
|
||||
|
||||
// props — always an object bag.
|
||||
const props = block.props
|
||||
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
|
||||
errors.push(`${path}.props must be an object`)
|
||||
}
|
||||
|
||||
// type — must resolve to a registered block.
|
||||
const def = typeof block.type === 'string' ? getBlock(block.type) : null
|
||||
if (!def) {
|
||||
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
|
||||
return // can't validate props or nesting without a definition
|
||||
}
|
||||
|
||||
// Per-block prop schema from the registry.
|
||||
if (def.schema && props && typeof props === 'object') {
|
||||
let schemaErrors = []
|
||||
try {
|
||||
schemaErrors = def.schema(props) || []
|
||||
} catch (err) {
|
||||
schemaErrors = [`schema threw: ${err.message}`]
|
||||
}
|
||||
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
|
||||
}
|
||||
|
||||
// Nesting: only container blocks may hold sub-blocks, capped at one level.
|
||||
if (def.container) {
|
||||
if (nested) {
|
||||
errors.push(`${path} is a container and may not be nested inside another container`)
|
||||
return
|
||||
}
|
||||
for (const slot of def.containerSlots) {
|
||||
const sub = props ? props[slot] : undefined
|
||||
if (sub === undefined) continue // an empty slot is allowed
|
||||
if (!Array.isArray(sub)) {
|
||||
errors.push(`${path}.props.${slot} must be an array of blocks`)
|
||||
continue
|
||||
}
|
||||
if (sub.length > MAX_SUBBLOCKS) {
|
||||
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
|
||||
}
|
||||
sub.forEach((child, j) => {
|
||||
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
|
||||
68
server/src/model/announceJobs/announceJobs.db.js
Normal file
68
server/src/model/announceJobs/announceJobs.db.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, post_id, status, ' +
|
||||
'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
|
||||
'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
|
||||
'created_at, updated_at'
|
||||
|
||||
// Whitelist so a `leg` value can be interpolated into a column name safely — it
|
||||
// never comes from raw user input, but keep the guard explicit.
|
||||
const LEGS = ['towncrier', 'discord']
|
||||
function assertLeg(leg) {
|
||||
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
}
|
||||
|
||||
async function create(postId) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findByPostId(postId) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
[postId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Jobs with at least one leg that is due now: pending and either never scheduled
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
|
||||
async function findDue(now = new Date(), limit = 25) {
|
||||
return query(
|
||||
`SELECT ${COLS} FROM announce_jobs
|
||||
WHERE (towncrier_status = 'pending'
|
||||
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
|
||||
OR (discord_status = 'pending'
|
||||
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
|
||||
ORDER BY id ASC
|
||||
LIMIT ?`,
|
||||
[now, now, limit],
|
||||
)
|
||||
}
|
||||
|
||||
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
|
||||
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
|
||||
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||
assertLeg(leg)
|
||||
await query(
|
||||
`UPDATE announce_jobs SET
|
||||
${leg}_status = ?,
|
||||
${leg}_attempts = ?,
|
||||
${leg}_last_error = ?,
|
||||
${leg}_next_attempt_at = ?
|
||||
WHERE id = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
|
||||
)
|
||||
}
|
||||
|
||||
async function setStatus(id, status) {
|
||||
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
|
||||
}
|
||||
|
||||
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
127
server/src/model/announceJobs/announceJobs.logic.js
Normal file
127
server/src/model/announceJobs/announceJobs.logic.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// ── Announcement pipeline: pure logic ──────────────────────────────────────
|
||||
//
|
||||
// No DB, no network — just the decisions the worker and model make, kept here so
|
||||
// they are unit-testable in isolation (server/test/announceJobs.test.js):
|
||||
// • buildTownCrierText — turn a post into sidecar-safe town-crier lines
|
||||
// • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
|
||||
// / terminal, so a data problem fails fast and a transient outage retries
|
||||
// • scheduleAfter — exponential backoff schedule + the attempt cap
|
||||
// • rollupStatus — derive the parent job status from the two legs
|
||||
|
||||
const { deriveExcerpt } = require('../../utils/sanitizeHtml')
|
||||
|
||||
// Sidecar town-crier caps, mirrored from the admin route validation
|
||||
// (admin.routes.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||
// We pre-truncate to these so a published post never bounces with towncrier.error.
|
||||
const MAX_LINES = 8
|
||||
const MAX_LINE_LEN = 200
|
||||
|
||||
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
|
||||
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
|
||||
// the post's admin panel. Shared by both legs.
|
||||
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
|
||||
const MAX_ATTEMPTS = BACKOFF_MS.length
|
||||
|
||||
// Trim to a hard length, appending an ellipsis only when something was cut.
|
||||
function clamp(value, max) {
|
||||
const s = String(value == null ? '' : value)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (s.length <= max) return s
|
||||
return `${s.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
// The public link that goes in the announcement. News has no per-post route
|
||||
// (App.jsx only has the /site/news list), so we link the list — matches the
|
||||
// pre-pipeline Discord announce behavior.
|
||||
function articleUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
|
||||
}
|
||||
|
||||
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
|
||||
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
|
||||
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
|
||||
function buildTownCrierText(post, { baseUrl } = {}) {
|
||||
const title = clamp(post.title, MAX_LINE_LEN)
|
||||
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
|
||||
const lines = [title]
|
||||
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
|
||||
if (excerpt) lines.push(excerpt)
|
||||
const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
|
||||
if (url) lines.push(url)
|
||||
return lines.filter(Boolean).slice(0, MAX_LINES)
|
||||
}
|
||||
|
||||
// ── Result classification ──────────────────────────────────────────────────
|
||||
// Both clients return { ok, status, error }. Map that to one of:
|
||||
// done — delivered, mark the leg done
|
||||
// retry — transient (shard restarting, bot down, network); back off + retry
|
||||
// terminal — will never succeed as-is (over caps, bad auth/config); fail now
|
||||
|
||||
function classifyTownCrier(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
const status = result ? result.status : 0
|
||||
// 400 = over the line/duration caps (a data problem — do NOT retry).
|
||||
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
|
||||
if (status === 400 || status === 401 || status === 409) {
|
||||
return { outcome: 'terminal', error: legError(result) }
|
||||
}
|
||||
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
|
||||
// configured yet), and any other 5xx are transient — retry.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
}
|
||||
|
||||
function classifyDiscord(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
// The bot's /internal/announce collapses failures (503 = not connected,
|
||||
// 400 = no news channel configured) without surfacing Discord's own
|
||||
// retry_after, so there is no reliable terminal signal to key on here. Retry
|
||||
// every failure on the shared backoff; a genuine config problem simply
|
||||
// exhausts its attempts and lands as `failed` in the admin panel, where the
|
||||
// per-leg retry button re-runs it after the channel is set.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
}
|
||||
|
||||
function legError(result) {
|
||||
if (!result) return 'no response'
|
||||
if (result.status) {
|
||||
return result.data && result.data.message
|
||||
? `${result.status}: ${result.data.message}`
|
||||
: result.error || `status ${result.status}`
|
||||
}
|
||||
return result.error || 'request failed'
|
||||
}
|
||||
|
||||
// Given the number of attempts already made (>= 1), how long to wait before the
|
||||
// next one — or null when the cap is reached and the leg should be failed.
|
||||
function scheduleAfter(attempts) {
|
||||
if (attempts >= MAX_ATTEMPTS) return null
|
||||
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
|
||||
}
|
||||
|
||||
// Parent job status derived from the two leg statuses:
|
||||
// done — both legs delivered
|
||||
// failed — both legs gave up
|
||||
// partial — at least one leg reached a terminal state while the other has not
|
||||
// matched it (still pending/retrying, or the opposite terminal state)
|
||||
// pending — neither leg is terminal yet
|
||||
function rollupStatus(towncrierStatus, discordStatus) {
|
||||
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
|
||||
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
|
||||
const terminal = (s) => s === 'done' || s === 'failed'
|
||||
if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_LINES,
|
||||
MAX_LINE_LEN,
|
||||
MAX_ATTEMPTS,
|
||||
BACKOFF_MS,
|
||||
buildTownCrierText,
|
||||
articleUrl,
|
||||
classifyTownCrier,
|
||||
classifyDiscord,
|
||||
scheduleAfter,
|
||||
rollupStatus,
|
||||
}
|
||||
116
server/src/model/announceJobs/announceJobs.model.js
Normal file
116
server/src/model/announceJobs/announceJobs.model.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── Announcement pipeline: orchestration ────────────────────────────────────
|
||||
//
|
||||
// Sits between the DB rows and the worker: creates jobs on publish, records each
|
||||
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
|
||||
// announced_at when both legs land, and resets a leg for the admin retry button.
|
||||
// The pure decisions (backoff, rollup, classification) live in .logic.js.
|
||||
|
||||
const db = require('./announceJobs.db')
|
||||
const logic = require('./announceJobs.logic')
|
||||
const posts = require('../posts/posts.model')
|
||||
const log = require('../../utils/logger')('announce')
|
||||
|
||||
// Enqueue an announcement for a freshly-published news post: one job row (both
|
||||
// legs pending, due immediately) plus a back-pointer on the post so the admin
|
||||
// panel can find it. Returns the new job id.
|
||||
async function enqueue(postId) {
|
||||
const jobId = await db.create(postId)
|
||||
await posts.linkAnnounceJob(postId, jobId)
|
||||
log.info('announce job enqueued', { jobId, postId })
|
||||
return jobId
|
||||
}
|
||||
|
||||
// Should publishing this post fire the pipeline? Only on a real transition INTO
|
||||
// "published news" — a false→true publish while in news, or a category change
|
||||
// into news while already published — and never twice (guarded by the post's
|
||||
// existing announce_job_id). Editing an already-announced post does not re-fire.
|
||||
function shouldEnqueue(post, { wasPublished, wasNews }) {
|
||||
if (!post || post.category !== 'news' || !post.published) return false
|
||||
if (post.announce_job_id) return false
|
||||
const wasNewsPublished = Boolean(wasPublished) && Boolean(wasNews)
|
||||
return !wasNewsPublished
|
||||
}
|
||||
|
||||
// Convenience used by the post controller: enqueue iff shouldEnqueue. Never
|
||||
// throws — a pipeline hiccup must not break saving/publishing a post.
|
||||
async function enqueueIfNeeded(post, transition) {
|
||||
try {
|
||||
if (!shouldEnqueue(post, transition)) return null
|
||||
return await enqueue(post.id)
|
||||
} catch (err) {
|
||||
log.error('enqueueIfNeeded failed', { postId: post && post.id, message: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of
|
||||
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump
|
||||
// the attempt count and schedule the next run (or fail the leg once the cap is
|
||||
// hit). Returns the updated job row.
|
||||
async function recordOutcome(job, leg, { outcome, error }) {
|
||||
const attempts = Number(job[`${leg}_attempts`]) || 0
|
||||
|
||||
if (outcome === 'done') {
|
||||
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
|
||||
} else if (outcome === 'terminal') {
|
||||
await db.updateLeg(job.id, leg, { status: 'failed', attempts: attempts + 1, lastError: error, nextAttemptAt: null })
|
||||
log.warn('announce leg failed (terminal)', { jobId: job.id, leg, error })
|
||||
} else {
|
||||
const nextAttempts = attempts + 1
|
||||
const delay = logic.scheduleAfter(nextAttempts)
|
||||
if (delay === null) {
|
||||
await db.updateLeg(job.id, leg, { status: 'failed', attempts: nextAttempts, lastError: error, nextAttemptAt: null })
|
||||
log.warn('announce leg failed (retries exhausted)', { jobId: job.id, leg, attempts: nextAttempts, error })
|
||||
} else {
|
||||
const nextAttemptAt = new Date(Date.now() + delay)
|
||||
await db.updateLeg(job.id, leg, { status: 'pending', attempts: nextAttempts, lastError: error, nextAttemptAt })
|
||||
log.info('announce leg retry scheduled', { jobId: job.id, leg, attempts: nextAttempts, nextAttemptAt })
|
||||
}
|
||||
}
|
||||
|
||||
return refreshStatus(job.id)
|
||||
}
|
||||
|
||||
// Recompute and persist the parent status from the two legs; stamp the post's
|
||||
// announced_at the moment both legs have delivered.
|
||||
async function refreshStatus(jobId) {
|
||||
const job = await db.findById(jobId)
|
||||
if (!job) return null
|
||||
const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
|
||||
if (status !== job.status) await db.setStatus(jobId, status)
|
||||
job.status = status
|
||||
if (status === 'done') {
|
||||
try {
|
||||
await posts.markAnnounced(job.post_id)
|
||||
} catch (err) {
|
||||
log.warn('markAnnounced failed', { jobId, postId: job.post_id, message: err.message })
|
||||
}
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// Admin retry button: reset one leg to pending, clear its error/backoff, and let
|
||||
// the worker pick it up on the next tick. Resets the attempt count so a retry
|
||||
// after a config fix gets a full budget again.
|
||||
async function resetLeg(postId, leg) {
|
||||
if (!db.LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
const job = await db.findByPostId(postId)
|
||||
if (!job) return null
|
||||
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
|
||||
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
|
||||
return refreshStatus(job.id)
|
||||
}
|
||||
|
||||
async function getByPostId(postId) {
|
||||
return db.findByPostId(postId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enqueue,
|
||||
shouldEnqueue,
|
||||
enqueueIfNeeded,
|
||||
recordOutcome,
|
||||
refreshStatus,
|
||||
resetLeg,
|
||||
getByPostId,
|
||||
}
|
||||
28
server/src/model/emailConfig/emailConfig.db.js
Normal file
28
server/src/model/emailConfig/emailConfig.db.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
|
||||
|
||||
// Singleton row (id = 1). Returns null until the admin connects Gmail for the first time.
|
||||
async function get() {
|
||||
const rows = await query(`SELECT ${COLS} FROM email_config WHERE id = 1 LIMIT 1`)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Upsert the singleton row. `fields` are column values already prepared by the
|
||||
// model (refresh token pre-encrypted). Only the provided columns are written/updated.
|
||||
async function upsert(fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const vals = cols.map((c) => fields[c])
|
||||
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO email_config (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
vals,
|
||||
)
|
||||
return get()
|
||||
}
|
||||
|
||||
module.exports = { get, upsert }
|
||||
92
server/src/model/emailConfig/emailConfig.model.js
Normal file
92
server/src/model/emailConfig/emailConfig.model.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split:
|
||||
// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the
|
||||
// mailer at send time) decrypts the refresh token. The admin-facing getSafe()
|
||||
// never includes it — callers see only `hasRefreshToken`.
|
||||
|
||||
const db = require('./emailConfig.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
return {
|
||||
provider: 'gmail_oauth2',
|
||||
enabled: false,
|
||||
senderEmail: null,
|
||||
senderName: null,
|
||||
hasRefreshToken: false,
|
||||
status: 'unconfigured',
|
||||
statusDetail: null,
|
||||
lastVerifiedAt: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: row.provider || 'gmail_oauth2',
|
||||
enabled: Boolean(row.enabled),
|
||||
senderEmail: row.sender_email || null,
|
||||
senderName: row.sender_name || null,
|
||||
hasRefreshToken: Boolean(row.refresh_token_enc),
|
||||
status: row.status || 'unconfigured',
|
||||
statusDetail: row.status_detail || null,
|
||||
lastVerifiedAt: row.last_verified_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSafe() {
|
||||
return toSafe(await db.get())
|
||||
}
|
||||
|
||||
// Decrypted refresh token included — server-side only (building the mailer's
|
||||
// OAuth2 transport). Returns null when no row exists yet.
|
||||
async function getWithSecret() {
|
||||
const row = await db.get()
|
||||
if (!row) return null
|
||||
return {
|
||||
...toSafe(row),
|
||||
refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means
|
||||
// "leave the existing token unchanged" (same convention as botConfig.save).
|
||||
async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) {
|
||||
const fields = {}
|
||||
if (senderEmail !== undefined) fields.sender_email = senderEmail
|
||||
if (senderName !== undefined) fields.sender_name = senderName
|
||||
if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken)
|
||||
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||
if (status !== undefined) fields.status = status
|
||||
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
// Clear the stored credential and disable sending (admin "Disconnect").
|
||||
async function disconnect(updatedBy) {
|
||||
const row = await db.upsert({
|
||||
refresh_token_enc: null,
|
||||
sender_email: null,
|
||||
enabled: 0,
|
||||
status: 'unconfigured',
|
||||
status_detail: null,
|
||||
last_verified_at: null,
|
||||
updated_by: updatedBy ?? null,
|
||||
})
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
// Record the outcome of the last send / verification so the admin panel has
|
||||
// something to show. `lastVerifiedAt` may arrive as a Date or ISO string.
|
||||
async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) {
|
||||
const fields = {}
|
||||
if (status !== undefined) fields.status = status
|
||||
if (statusDetail !== undefined) fields.status_detail = statusDetail ? String(statusDetail).slice(0, 500) : null
|
||||
if (lastVerifiedAt !== undefined) {
|
||||
fields.last_verified_at = lastVerifiedAt ? new Date(lastVerifiedAt) : null
|
||||
}
|
||||
if (Object.keys(fields).length === 0) return getSafe()
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus }
|
||||
79
server/src/model/pages/pages.db.js
Normal file
79
server/src/model/pages/pages.db.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// `blocks` is stored as a JSON string (MEDIUMTEXT) and parsed in the model.
|
||||
const COLS = [
|
||||
'id', 'slug', 'title', 'blocks', 'status', 'protected', 'author_id',
|
||||
'seo_title', 'meta_description', 'og_image', 'canonical_url', 'robots',
|
||||
'layout', 'show_in_nav', 'nav_group', 'nav_order',
|
||||
'created_at', 'updated_at', 'published_at',
|
||||
].join(', ')
|
||||
|
||||
// Admin list — every page, newest first. Excludes the (potentially large)
|
||||
// blocks payload; callers that need it fetch the row by id/slug.
|
||||
async function listSummaries() {
|
||||
return query(
|
||||
`SELECT id, slug, title, status, protected, show_in_nav, nav_group, nav_order,
|
||||
updated_at, published_at
|
||||
FROM pages ORDER BY updated_at DESC, id DESC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM pages WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findBySlug(slug) {
|
||||
const rows = await query(`SELECT ${COLS} FROM pages WHERE slug = ? LIMIT 1`, [slug])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Insert a fully-formed column map. `blocks` must already be a JSON string.
|
||||
async function insert(page) {
|
||||
const res = await query(
|
||||
`INSERT INTO pages
|
||||
(slug, title, blocks, status, protected, author_id,
|
||||
seo_title, meta_description, og_image, canonical_url, robots,
|
||||
layout, show_in_nav, nav_group, nav_order, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
page.slug,
|
||||
page.title,
|
||||
page.blocks,
|
||||
page.status,
|
||||
page.protected ? 1 : 0,
|
||||
page.author_id ?? null,
|
||||
page.seo_title ?? null,
|
||||
page.meta_description ?? null,
|
||||
page.og_image ?? null,
|
||||
page.canonical_url ?? null,
|
||||
page.robots ?? null,
|
||||
page.layout ?? 'default',
|
||||
page.show_in_nav ? 1 : 0,
|
||||
page.nav_group ?? null,
|
||||
page.nav_order ?? null,
|
||||
page.published_at ?? null,
|
||||
],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Update only the provided columns. Keys must be real column names (the model
|
||||
// builds this map from a whitelist, never straight from the request body).
|
||||
async function update(id, fields) {
|
||||
const cols = []
|
||||
const params = []
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
cols.push(`${key} = ?`)
|
||||
params.push(val)
|
||||
}
|
||||
if (cols.length === 0) return
|
||||
params.push(id)
|
||||
await query(`UPDATE pages SET ${cols.join(', ')} WHERE id = ?`, params)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
return query('DELETE FROM pages WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
module.exports = { listSummaries, findById, findBySlug, insert, update, remove }
|
||||
306
server/src/model/pages/pages.model.js
Normal file
306
server/src/model/pages/pages.model.js
Normal file
@@ -0,0 +1,306 @@
|
||||
// CMS pages model. Owns the rules the API surface must not bypass:
|
||||
// - blocks are validated against the block registry and sanitized on every
|
||||
// save (the authoritative gate — a direct API call can't skip it);
|
||||
// - the DB row is mapped to/from the grouped API shape (metadata / settings);
|
||||
// - slug is validated + reserved-checked at create and is immutable after;
|
||||
// - `protected` can be turned ON via a normal update but only OFF via the
|
||||
// dedicated unprotect path (see unprotect()), enforced here regardless of
|
||||
// what the request body contains.
|
||||
//
|
||||
// Business/validation failures throw a PageError carrying an HTTP status + code
|
||||
// so the controller can translate without knowing the rules.
|
||||
|
||||
const pagesDb = require('./pages.db')
|
||||
const { isReservedSlug } = require('./reservedSlugs')
|
||||
const { validateBlocks, sanitizeBlocks } = require('../../blocks')
|
||||
|
||||
const LAYOUTS = ['default', 'full_width', 'landing']
|
||||
const NAV_GROUPS = ['main', 'footer', 'account', 'hidden']
|
||||
const STATUSES = ['draft', 'published']
|
||||
const SLUG_RE = /^[a-z0-9-]+$/
|
||||
const MAX_SLUG = 160
|
||||
|
||||
class PageError extends Error {
|
||||
constructor(status, code, message, extra) {
|
||||
super(message)
|
||||
this.name = 'PageError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
if (extra) Object.assign(this, extra)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serialization (row → API shape) ───────────────────────────────────
|
||||
function parseBlocks(raw) {
|
||||
if (raw == null || raw === '') return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function serialize(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
blocks: parseBlocks(row.blocks),
|
||||
metadata: {
|
||||
seoTitle: row.seo_title,
|
||||
metaDescription: row.meta_description,
|
||||
ogImage: row.og_image,
|
||||
canonicalUrl: row.canonical_url,
|
||||
robots: row.robots,
|
||||
},
|
||||
settings: {
|
||||
layout: row.layout,
|
||||
showInNav: Boolean(row.show_in_nav),
|
||||
navGroup: row.nav_group,
|
||||
navOrder: row.nav_order,
|
||||
protected: Boolean(row.protected),
|
||||
},
|
||||
authorId: row.author_id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
publishedAt: row.published_at,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSummary(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
protected: Boolean(row.protected),
|
||||
showInNav: Boolean(row.show_in_nav),
|
||||
navGroup: row.nav_group,
|
||||
navOrder: row.nav_order,
|
||||
updatedAt: row.updated_at,
|
||||
publishedAt: row.published_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field validation / mapping ────────────────────────────────────────
|
||||
function assertSlug(slug) {
|
||||
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > MAX_SLUG) {
|
||||
throw new PageError(400, 'invalid_slug', 'Slug must be lowercase letters, numbers and dashes.')
|
||||
}
|
||||
if (isReservedSlug(slug)) {
|
||||
throw new PageError(400, 'reserved_slug', `"${slug}" is a reserved slug.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatus(status) {
|
||||
if (status !== undefined && !STATUSES.includes(status)) {
|
||||
throw new PageError(400, 'invalid_status', `status must be one of ${STATUSES.join(', ')}.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate + sanitize blocks; returns a JSON string ready to store.
|
||||
function buildBlocks(blocks) {
|
||||
const { valid, errors } = validateBlocks(blocks)
|
||||
if (!valid) {
|
||||
throw new PageError(400, 'invalid_blocks', 'One or more blocks are invalid.', { errors })
|
||||
}
|
||||
return JSON.stringify(sanitizeBlocks(blocks))
|
||||
}
|
||||
|
||||
// Map the grouped `metadata` object to DB columns. Only keys present in the
|
||||
// input are returned, so a PATCH touches only what it sends.
|
||||
function mapMetadata(metadata) {
|
||||
const cols = {}
|
||||
if (!metadata || typeof metadata !== 'object') return cols
|
||||
const strOrNull = (v, max, field) => {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
if (typeof v !== 'string' || v.length > max) {
|
||||
throw new PageError(400, 'invalid_metadata', `${field} must be a string of at most ${max} characters.`)
|
||||
}
|
||||
return v
|
||||
}
|
||||
if ('seoTitle' in metadata) cols.seo_title = strOrNull(metadata.seoTitle, 200, 'seoTitle')
|
||||
if ('metaDescription' in metadata) cols.meta_description = strOrNull(metadata.metaDescription, 400, 'metaDescription')
|
||||
if ('ogImage' in metadata) cols.og_image = strOrNull(metadata.ogImage, 500, 'ogImage')
|
||||
if ('canonicalUrl' in metadata) cols.canonical_url = strOrNull(metadata.canonicalUrl, 500, 'canonicalUrl')
|
||||
if ('robots' in metadata) cols.robots = strOrNull(metadata.robots, 100, 'robots')
|
||||
return cols
|
||||
}
|
||||
|
||||
// Map the grouped `settings` object to DB columns (except `protected`, which is
|
||||
// handled by the caller so the unprotect rule stays centralized).
|
||||
function mapSettings(settings) {
|
||||
const cols = {}
|
||||
if (!settings || typeof settings !== 'object') return cols
|
||||
if ('layout' in settings) {
|
||||
if (!LAYOUTS.includes(settings.layout)) {
|
||||
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
|
||||
}
|
||||
cols.layout = settings.layout
|
||||
}
|
||||
if ('showInNav' in settings) {
|
||||
if (typeof settings.showInNav !== 'boolean') {
|
||||
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
|
||||
}
|
||||
cols.show_in_nav = settings.showInNav ? 1 : 0
|
||||
}
|
||||
if ('navGroup' in settings) {
|
||||
if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) {
|
||||
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
|
||||
}
|
||||
cols.nav_group = settings.navGroup
|
||||
}
|
||||
if ('navOrder' in settings) {
|
||||
if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) {
|
||||
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
|
||||
}
|
||||
cols.nav_order = settings.navOrder
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────
|
||||
async function list() {
|
||||
const rows = await pagesDb.listSummaries()
|
||||
return rows.map(serializeSummary)
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return serialize(await pagesDb.findById(id))
|
||||
}
|
||||
|
||||
// Public read by slug. Non-admins only see published pages (returns null for a
|
||||
// draft so the caller can 404 it indistinguishably from a missing page).
|
||||
async function getBySlug(slug, { includeUnpublished = false } = {}) {
|
||||
const row = await pagesDb.findBySlug(slug)
|
||||
if (!row) return null
|
||||
if (!includeUnpublished && row.status !== 'published') return null
|
||||
return serialize(row)
|
||||
}
|
||||
|
||||
// Raw row (for the controller's protected/status checks without re-serializing).
|
||||
async function getRawById(id) {
|
||||
return pagesDb.findById(id)
|
||||
}
|
||||
|
||||
// ── Writes ────────────────────────────────────────────────────────────
|
||||
async function create(input, authorId) {
|
||||
const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
|
||||
assertSlug(slug)
|
||||
assertStatus(status)
|
||||
if (typeof title !== 'string' || title.trim() === '' || title.length > 200) {
|
||||
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
|
||||
}
|
||||
|
||||
const row = {
|
||||
slug,
|
||||
title: title.trim(),
|
||||
blocks: buildBlocks(blocks),
|
||||
status,
|
||||
author_id: authorId,
|
||||
...mapMetadata(metadata),
|
||||
...mapSettings(settings),
|
||||
protected: settings && settings.protected === true ? 1 : 0,
|
||||
published_at: status === 'published' ? new Date() : null,
|
||||
}
|
||||
|
||||
let id
|
||||
try {
|
||||
id = await pagesDb.insert(row)
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
||||
throw new PageError(409, 'slug_taken', `A page with slug "${slug}" already exists.`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
return getById(id)
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
const current = await pagesDb.findById(id)
|
||||
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
|
||||
|
||||
// slug is immutable after create — reject an attempt rather than silently
|
||||
// ignoring it, so the caller knows their change didn't take.
|
||||
if (patch.slug !== undefined && patch.slug !== current.slug) {
|
||||
throw new PageError(400, 'slug_immutable', 'A page slug cannot be changed after creation.')
|
||||
}
|
||||
|
||||
const fields = {}
|
||||
|
||||
if (patch.title !== undefined) {
|
||||
if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) {
|
||||
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
|
||||
}
|
||||
fields.title = patch.title.trim()
|
||||
}
|
||||
|
||||
if (patch.blocks !== undefined) {
|
||||
fields.blocks = buildBlocks(patch.blocks)
|
||||
}
|
||||
|
||||
if (patch.status !== undefined) {
|
||||
assertStatus(patch.status)
|
||||
fields.status = patch.status
|
||||
// Stamp published_at the first time a page becomes published.
|
||||
if (patch.status === 'published' && !current.published_at) {
|
||||
fields.published_at = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(fields, mapMetadata(patch.metadata))
|
||||
Object.assign(fields, mapSettings(patch.settings))
|
||||
|
||||
// Protected transitions: ON is allowed here; OFF is not (must go through the
|
||||
// password-gated unprotect endpoint), regardless of the request body.
|
||||
if (patch.settings && 'protected' in patch.settings) {
|
||||
const want = patch.settings.protected
|
||||
if (want === true) {
|
||||
fields.protected = 1
|
||||
} else if (want === false && current.protected) {
|
||||
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
|
||||
}
|
||||
// want === false while already unprotected → no-op.
|
||||
}
|
||||
|
||||
await pagesDb.update(id, fields)
|
||||
return getById(id)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const current = await pagesDb.findById(id)
|
||||
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
|
||||
if (current.protected) {
|
||||
throw new PageError(403, 'page_protected', 'This page is protected and cannot be deleted.')
|
||||
}
|
||||
await pagesDb.remove(id)
|
||||
return { id }
|
||||
}
|
||||
|
||||
// Flip protected → false. The controller performs the password step-up before
|
||||
// calling this; the model just applies it.
|
||||
async function unprotect(id) {
|
||||
const current = await pagesDb.findById(id)
|
||||
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
|
||||
await pagesDb.update(id, { protected: 0 })
|
||||
return getById(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PageError,
|
||||
LAYOUTS,
|
||||
NAV_GROUPS,
|
||||
STATUSES,
|
||||
serialize,
|
||||
list,
|
||||
getById,
|
||||
getBySlug,
|
||||
getRawById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
unprotect,
|
||||
}
|
||||
28
server/src/model/pages/reservedSlugs.js
Normal file
28
server/src/model/pages/reservedSlugs.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Slugs a CMS page may not claim, because a top-level page lives at `/:slug` and
|
||||
// must never shadow an existing named route (SPA route or API namespace). The
|
||||
// catch-all page route is matched only after these, but reserving the names up
|
||||
// front gives the admin a clear "that slug is reserved" error at create time
|
||||
// instead of a silently unreachable page.
|
||||
//
|
||||
// Kept as a Set of lowercase single-segment slugs. Page slugs are validated to a
|
||||
// single segment (^[a-z0-9-]+$) so we only need to guard first path segments.
|
||||
|
||||
const RESERVED_SLUGS = new Set([
|
||||
// API / infrastructure
|
||||
'api', 'internal', 'uploads', 'assets', 'static', 'public',
|
||||
// Auth / account
|
||||
'login', 'logout', 'register', 'account', 'auth',
|
||||
// Admin app
|
||||
'admin',
|
||||
// Existing top-level SPA sections
|
||||
'site', 'wiki', 'news', 'newsletter', 'screenshots', 'five-on-friday', 'about', 'status',
|
||||
// Page-builder's own surface
|
||||
'pages', 'preview',
|
||||
])
|
||||
|
||||
/** @returns {boolean} true if `slug` collides with a reserved route name. */
|
||||
function isReservedSlug(slug) {
|
||||
return RESERVED_SLUGS.has(String(slug).toLowerCase())
|
||||
}
|
||||
|
||||
module.exports = { RESERVED_SLUGS, isReservedSlug }
|
||||
@@ -1,7 +1,7 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, category, title, slug, excerpt, body, image_url, published, author_id, created_at, updated_at, published_at'
|
||||
'id, category, title, slug, excerpt, body, image_url, published, author_id, created_at, updated_at, published_at, announced_at, announce_job_id'
|
||||
|
||||
// Published posts for a category, newest first — public feed.
|
||||
async function listPublished(category) {
|
||||
|
||||
@@ -78,6 +78,15 @@ async function setPublished(id, published) {
|
||||
return postsDb.findById(id)
|
||||
}
|
||||
|
||||
// Announcement pipeline back-pointers (see model/announceJobs).
|
||||
async function linkAnnounceJob(id, jobId) {
|
||||
await postsDb.update(id, { announce_job_id: jobId })
|
||||
}
|
||||
|
||||
async function markAnnounced(id, at = new Date()) {
|
||||
await postsDb.update(id, { announced_at: at })
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
return postsDb.remove(id)
|
||||
}
|
||||
@@ -103,6 +112,8 @@ module.exports = {
|
||||
create,
|
||||
update,
|
||||
setPublished,
|
||||
linkAnnounceJob,
|
||||
markAnnounced,
|
||||
remove,
|
||||
counts,
|
||||
}
|
||||
|
||||
41
server/src/model/shardEvents/shardEvents.db.js
Normal file
41
server/src/model/shardEvents/shardEvents.db.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect
|
||||
// backfill overlap) is silently skipped rather than duplicated. Returns true if
|
||||
// a new row was actually inserted.
|
||||
async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
||||
const res = await query(
|
||||
`INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[kind, t, bootId || null, JSON.stringify(payload), dedupeKey],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
// Recent events, newest first. Filter by a single `kind`, or an allowlist of
|
||||
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||
// staff/sensitive kinds. limit is clamped by the model.
|
||||
async function list({ kind, kinds, limit }) {
|
||||
if (kinds && kinds.length) {
|
||||
const placeholders = kinds.map(() => '?').join(', ')
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`,
|
||||
[...kinds, limit],
|
||||
)
|
||||
}
|
||||
if (kind) {
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`,
|
||||
[kind, limit],
|
||||
)
|
||||
}
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events ORDER BY t DESC LIMIT ?`,
|
||||
[limit],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { insertIgnore, list }
|
||||
53
server/src/model/shardEvents/shardEvents.model.js
Normal file
53
server/src/model/shardEvents/shardEvents.model.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Append-only shard event log. The WS ingest dispatcher calls append() for the
|
||||
// notable kinds; the public/admin read endpoints call list(). The DB layer only
|
||||
// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across
|
||||
// WS-reconnect backfill.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./shardEvents.db')
|
||||
|
||||
const MAX_LIMIT = 1000
|
||||
const DEFAULT_LIMIT = 100
|
||||
|
||||
// Stable stringify — keys sorted — so the dedupe hash is independent of the
|
||||
// property order the sidecar happened to serialize with.
|
||||
function stableStringify(value) {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||
const keys = Object.keys(value).sort()
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
||||
}
|
||||
|
||||
// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same
|
||||
// kind, same timestamp, same body) collapse to one row.
|
||||
function dedupeKey(kind, t, payload) {
|
||||
return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex')
|
||||
}
|
||||
|
||||
// Append one event. Returns true if a new row was inserted (false = deduped).
|
||||
async function append({ kind, t, bootId, payload }) {
|
||||
return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) })
|
||||
}
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
const n = Number(limit)
|
||||
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT
|
||||
return Math.min(Math.floor(n), MAX_LIMIT)
|
||||
}
|
||||
|
||||
// Recent events, newest first. Each row's JSON payload is parsed back to an
|
||||
// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
|
||||
async function list({ kind, kinds, limit } = {}) {
|
||||
const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
t: row.t,
|
||||
bootId: row.boot_id || null,
|
||||
// mariadb returns JSON columns as strings on some versions; parse defensively.
|
||||
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
||||
createdAt: row.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { append, list, dedupeKey }
|
||||
36
server/src/model/shardLinks/shardLinks.db.js
Normal file
36
server/src/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'account, user_id, char_name, linked_at'
|
||||
|
||||
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||
// user (the sidecar already treats /link/confirm as authoritative).
|
||||
async function upsert({ account, userId, charName }) {
|
||||
await query(
|
||||
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||
[account, userId, charName || null],
|
||||
)
|
||||
return getByAccount(account)
|
||||
}
|
||||
|
||||
async function getByAccount(account) {
|
||||
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||
|
||||
async function isOwnedBy(account, userId) {
|
||||
const rows = await query(
|
||||
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||
[account, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove }
|
||||
34
server/src/model/shardLinks/shardLinks.model.js
Normal file
34
server/src/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||
// authoritative link (it tags the game account on /link/confirm); this model
|
||||
// records it locally so the player portal can list links and enforce ownership.
|
||||
|
||||
const db = require('./shardLinks.db')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
account: row.account,
|
||||
userId: row.user_id,
|
||||
charName: row.char_name || null,
|
||||
linkedAt: row.linked_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function link({ account, userId, charName }) {
|
||||
return toSafe(await db.upsert({ account, userId, charName }))
|
||||
}
|
||||
|
||||
async function listForUser(userId) {
|
||||
const rows = await db.listByUser(userId)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||
|
||||
async function getByAccount(account) {
|
||||
return toSafe(await db.getByAccount(account))
|
||||
}
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink }
|
||||
104
server/src/model/shardState/shardState.db.js
Normal file
104
server/src/model/shardState/shardState.db.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── Online players ─────────────────────────────────────────────────────────
|
||||
const ONLINE_COLS =
|
||||
'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at'
|
||||
|
||||
// Upsert one online player. `fields` already prepared by the model (only the
|
||||
// columns it wants to write); serial is required and is the primary key.
|
||||
async function upsertOnline(serial, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['serial', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
// Never overwrite an existing column with NULL on refresh (a char.vitals frame
|
||||
// that omits acct/name shouldn't blank what mob.login set) — COALESCE keeps the
|
||||
// prior value when the incoming one is NULL.
|
||||
const updates = cols.map((c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_online (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[serial, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial])
|
||||
const clearOnline = () => query('DELETE FROM shard_online')
|
||||
|
||||
async function countOnline() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM shard_online')
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
const listOnline = () =>
|
||||
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||
|
||||
// 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.
|
||||
const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
|
||||
|
||||
// Online players whose game account is linked to a STAFF website user. Joined
|
||||
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
|
||||
// effect immediately, regardless of whether the player has re-logged since
|
||||
// linking, then through to users so only staff roles are surfaced publicly.
|
||||
const listOnlineLinked = () =>
|
||||
query(
|
||||
`SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')}
|
||||
FROM shard_online o
|
||||
JOIN shard_account_links l ON l.account = o.acct
|
||||
JOIN users u ON u.id = l.user_id
|
||||
WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
|
||||
ORDER BY o.name ASC`,
|
||||
PUBLIC_ONLINE_ROLES,
|
||||
)
|
||||
|
||||
// ── Economy supply series ────────────────────────────────────────────────
|
||||
const insertEconomy = ({ accounts, gold, t }) =>
|
||||
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
|
||||
accounts ?? null,
|
||||
gold ?? null,
|
||||
t,
|
||||
])
|
||||
|
||||
const listEconomy = (limit) =>
|
||||
query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT ?', [limit])
|
||||
|
||||
async function latestEconomy() {
|
||||
const rows = await query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT 1')
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Houses / IDOC ────────────────────────────────────────────────────────
|
||||
const HOUSE_COLS =
|
||||
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
|
||||
|
||||
async function upsertHouse(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_houses (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[serial, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const listIdocHouses = () =>
|
||||
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
clearOnline,
|
||||
countOnline,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
insertEconomy,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdocHouses,
|
||||
}
|
||||
171
server/src/model/shardState/shardState.model.js
Normal file
171
server/src/model/shardState/shardState.model.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Live shard state derived from the WS feed: who is online, the gold-supply
|
||||
// series, and per-house decay stage. The ingest dispatcher calls the write
|
||||
// methods; the public read endpoints call the list/count methods. Writes take
|
||||
// camelCase semantic objects and map to the snake_case columns; only the keys
|
||||
// present are written (so a char.vitals refresh doesn't clobber login fields).
|
||||
|
||||
const db = require('./shardState.db')
|
||||
|
||||
const MAX_ECONOMY = 1000
|
||||
|
||||
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
|
||||
// partial refresh only touches the fields it carries.
|
||||
function onlineFields(data) {
|
||||
const map = {
|
||||
name: data.name,
|
||||
acct: data.acct,
|
||||
web_id: data.webId,
|
||||
map: data.map,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
z: data.z,
|
||||
hits: data.hits,
|
||||
hits_max: data.hitsMax,
|
||||
mana: data.mana,
|
||||
mana_max: data.manaMax,
|
||||
stam: data.stam,
|
||||
stam_max: data.stamMax,
|
||||
str: data.str,
|
||||
dex: data.dex,
|
||||
int: data.int,
|
||||
}
|
||||
const fields = {}
|
||||
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v
|
||||
return fields
|
||||
}
|
||||
|
||||
// Upsert an online player (mob.login) or refresh their vitals (char.vitals).
|
||||
async function upsertOnline(data) {
|
||||
if (!data || !data.serial) return
|
||||
await db.upsertOnline(data.serial, onlineFields(data))
|
||||
}
|
||||
|
||||
const setOffline = (serial) => db.removeOnline(serial)
|
||||
const clearOnline = () => db.clearOnline()
|
||||
const onlineCount = () => db.countOnline()
|
||||
|
||||
function shapeOnline(r) {
|
||||
return {
|
||||
serial: r.serial,
|
||||
name: r.name,
|
||||
acct: r.acct,
|
||||
webId: r.web_id,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
hits: r.hits,
|
||||
hitsMax: r.hits_max,
|
||||
mana: r.mana,
|
||||
manaMax: r.mana_max,
|
||||
stam: r.stam,
|
||||
stamMax: r.stam_max,
|
||||
str: r.str,
|
||||
dex: r.dex,
|
||||
int: r.int,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Only players whose account is linked to a website user (opt-in visibility).
|
||||
async function listOnlineLinked() {
|
||||
const rows = await db.listOnlineLinked()
|
||||
return rows.map(shapeOnline)
|
||||
}
|
||||
|
||||
async function listOnline() {
|
||||
const rows = await db.listOnline()
|
||||
return rows.map((r) => ({
|
||||
serial: r.serial,
|
||||
name: r.name,
|
||||
acct: r.acct,
|
||||
webId: r.web_id,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
hits: r.hits,
|
||||
hitsMax: r.hits_max,
|
||||
mana: r.mana,
|
||||
manaMax: r.mana_max,
|
||||
stam: r.stam,
|
||||
stamMax: r.stam_max,
|
||||
str: r.str,
|
||||
dex: r.dex,
|
||||
int: r.int,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
// Append a gold-supply sample (economy.supply).
|
||||
async function addEconomySample({ accounts, gold, t }) {
|
||||
await db.insertEconomy({ accounts, gold, t })
|
||||
}
|
||||
|
||||
async function listEconomy(limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY)
|
||||
const rows = await db.listEconomy(n)
|
||||
// Return oldest → newest for charting.
|
||||
return rows
|
||||
.map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t }))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
async function latestEconomy() {
|
||||
const r = await db.latestEconomy()
|
||||
return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null
|
||||
}
|
||||
|
||||
// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage.
|
||||
async function upsertHouse(data) {
|
||||
if (!data || !data.serial) return
|
||||
const fields = {
|
||||
stage: data.stage ?? null,
|
||||
map: data.map ?? null,
|
||||
x: data.x ?? null,
|
||||
y: data.y ?? null,
|
||||
z: data.z ?? null,
|
||||
region: data.region ?? null,
|
||||
name: data.name ?? null,
|
||||
owner_serial: data.ownerSerial ?? null,
|
||||
owner_acct: data.ownerAcct ?? null,
|
||||
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
async function listIdoc() {
|
||||
const rows = await db.listIdocHouses()
|
||||
return rows.map((r) => ({
|
||||
serial: r.serial,
|
||||
stage: r.stage,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
region: r.region,
|
||||
name: r.name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerAcct: r.owner_acct,
|
||||
builtOn: r.built_on,
|
||||
lastRefreshed: r.last_refreshed,
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
setOffline,
|
||||
clearOnline,
|
||||
onlineCount,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
addEconomySample,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdoc,
|
||||
}
|
||||
28
server/src/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
28
server/src/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at'
|
||||
|
||||
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
|
||||
async function get() {
|
||||
const rows = await query(`SELECT ${COLS} FROM uo_link_config WHERE id = 1 LIMIT 1`)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Upsert the singleton row. `fields` are column values already prepared by the
|
||||
// model (token pre-encrypted). Only the provided columns are written/updated.
|
||||
async function upsert(fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const vals = cols.map((c) => fields[c])
|
||||
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO uo_link_config (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
vals,
|
||||
)
|
||||
return get()
|
||||
}
|
||||
|
||||
module.exports = { get, upsert }
|
||||
85
server/src/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
85
server/src/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// uo-link sidecar connection config store. Mirrors botConfig/emailConfig: the DB
|
||||
// layer only ever sees ciphertext, and only getWithToken() (used server-side to
|
||||
// call the sidecar over REST/WS) decrypts it. The admin-facing getSafe() never
|
||||
// includes the token — it exposes only `hasToken`. A blank `token` on save means
|
||||
// "leave the existing token unchanged" (same convention as the other configs).
|
||||
|
||||
const db = require('./uoLinkConfig.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
return {
|
||||
baseUrl: process.env.UOLINK_BASE_URL || null,
|
||||
wsUrl: process.env.UOLINK_WS_URL || null,
|
||||
protocol: DEFAULT_PROTOCOL,
|
||||
enabled: false,
|
||||
hasToken: false,
|
||||
status: 'disconnected',
|
||||
statusDetail: null,
|
||||
pluginConnected: false,
|
||||
lastEventAt: null,
|
||||
bootId: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
baseUrl: row.base_url || null,
|
||||
wsUrl: row.ws_url || null,
|
||||
protocol: row.protocol || DEFAULT_PROTOCOL,
|
||||
enabled: Boolean(row.enabled),
|
||||
hasToken: Boolean(row.auth_token_enc),
|
||||
status: row.status || 'disconnected',
|
||||
statusDetail: row.status_detail || null,
|
||||
pluginConnected: Boolean(row.plugin_connected),
|
||||
lastEventAt: row.last_event_at || null,
|
||||
bootId: row.boot_id || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSafe() {
|
||||
return toSafe(await db.get())
|
||||
}
|
||||
|
||||
// Decrypted token included — server-side only (calling the sidecar's REST/WS
|
||||
// API). Returns null when nothing has been saved yet.
|
||||
async function getWithToken() {
|
||||
const row = await db.get()
|
||||
if (!row) return null
|
||||
return { ...toSafe(row), token: row.auth_token_enc ? secretBox.decrypt(row.auth_token_enc) : null }
|
||||
}
|
||||
|
||||
// Save admin-supplied config. `token` undefined or '' means "leave the existing
|
||||
// token unchanged" (same convention as botConfig.save).
|
||||
async function save({ baseUrl, wsUrl, token, protocol, enabled, updatedBy }) {
|
||||
const fields = {}
|
||||
if (baseUrl !== undefined) fields.base_url = baseUrl
|
||||
if (wsUrl !== undefined) fields.ws_url = wsUrl
|
||||
if (token) fields.auth_token_enc = secretBox.encrypt(token)
|
||||
if (protocol !== undefined) fields.protocol = protocol
|
||||
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
// Mirror the sidecar's last-reported connection state into the DB so the admin
|
||||
// panel has something to show between polls and the public status endpoint can
|
||||
// read it without a live round-trip.
|
||||
async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt, bootId }) {
|
||||
const fields = {}
|
||||
if (status !== undefined) fields.status = status
|
||||
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||
if (pluginConnected !== undefined) fields.plugin_connected = pluginConnected ? 1 : 0
|
||||
// lastEventAt may arrive as an ISO string (e.g. "2026-07-10T22:08:27Z"); the
|
||||
// mariadb DATETIME parser rejects the "T"/"Z", so hand it a real Date (same
|
||||
// fix as botConfig.recordStatus's last_connected_at).
|
||||
if (lastEventAt !== undefined) fields.last_event_at = lastEventAt ? new Date(lastEventAt) : null
|
||||
if (bootId !== undefined) fields.boot_id = bootId
|
||||
if (Object.keys(fields).length === 0) return getSafe()
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||
@@ -3,36 +3,25 @@ const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const botInternalClient = require('../../../utils/botInternalClient')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
|
||||
// Public base URL for links back to the site — same fallback pattern as
|
||||
// sso.controller.js's redirect_uri builder.
|
||||
function appBaseUrl(req) {
|
||||
const configured = process.env.APP_BASE_URL
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return `${req.protocol}://${req.get('host')}`
|
||||
}
|
||||
|
||||
// Fire-and-forget: announce a news post to Discord the moment it actually
|
||||
// transitions from unpublished to published — not on every save or on a
|
||||
// no-op re-publish of an already-live post. Never throws (botInternalClient
|
||||
// itself never rejects); a bot outage must never break publishing a post.
|
||||
function announceIfNewlyPublished(req, post, wasPublished) {
|
||||
if (!post || post.category !== 'news' || !post.published || wasPublished) return
|
||||
const base = appBaseUrl(req)
|
||||
// image_url is stored relative (e.g. "/uploads/xyz.png") — Discord embeds
|
||||
// require an absolute URL.
|
||||
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
|
||||
botInternalClient
|
||||
.announce({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${base}/site/news`,
|
||||
imageUrl,
|
||||
})
|
||||
.catch(() => {})
|
||||
// Fire the announcement pipeline the moment a post transitions INTO
|
||||
// "published news" — a false→true publish while in news, or a category change
|
||||
// into news while already published. Enqueues one announce_jobs row whose two
|
||||
// legs (in-game town crier + Discord #news) are then delivered with independent
|
||||
// retry by the dispatcher worker (utils/announceWorker). Fire-and-forget and
|
||||
// self-guarding (enqueueIfNeeded never throws and de-dupes via the post's
|
||||
// existing announce_job_id) so a pipeline hiccup never breaks saving a post.
|
||||
// Awaited (not fire-and-forget) because enqueue is purely local DB work — one
|
||||
// INSERT + a back-pointer UPDATE, no network — so it never blocks on the sidecar
|
||||
// or Discord (that happens later in the worker). Awaiting keeps the de-dup guard
|
||||
// (post.announce_job_id) reliable against rapid double-publishes. Still guarded:
|
||||
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
|
||||
async function announceIfNewlyPublished(post, transition) {
|
||||
await announceJobs.enqueueIfNeeded(post, transition)
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
@@ -119,7 +108,7 @@ async function createPost(req, res) {
|
||||
author_id: req.user.id,
|
||||
})
|
||||
await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } })
|
||||
announceIfNewlyPublished(req, created, false)
|
||||
await announceIfNewlyPublished(created, { wasPublished: false, wasNews: false })
|
||||
return res.status(201).json(created)
|
||||
} catch (err) {
|
||||
log.error('createPost', err)
|
||||
@@ -149,7 +138,10 @@ async function updatePost(req, res) {
|
||||
|
||||
const updated = await posts.update(id, fields)
|
||||
await activity.log({ req, action: 'post.update', detail: { id } })
|
||||
announceIfNewlyPublished(req, updated, Boolean(current.published))
|
||||
await announceIfNewlyPublished(updated, {
|
||||
wasPublished: Boolean(current.published),
|
||||
wasNews: current.category === 'news',
|
||||
})
|
||||
return res.json(updated)
|
||||
} catch (err) {
|
||||
log.error('updatePost', err)
|
||||
@@ -168,7 +160,10 @@ async function publishPost(req, res) {
|
||||
action: 'post.publish',
|
||||
detail: { id, published: Boolean(req.body.published) },
|
||||
})
|
||||
announceIfNewlyPublished(req, updated, Boolean(current.published))
|
||||
await announceIfNewlyPublished(updated, {
|
||||
wasPublished: Boolean(current.published),
|
||||
wasNews: current.category === 'news',
|
||||
})
|
||||
return res.json(updated)
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -186,6 +181,35 @@ async function deletePost(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/posts/:id/announce — the announcement job for a post (or null if it
|
||||
// was never announced), for the status panel on the post editor.
|
||||
async function getAnnounceStatus(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const job = await announceJobs.getByPostId(id)
|
||||
return res.json(job || null)
|
||||
} catch (err) {
|
||||
log.error('getAnnounceStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/posts/:id/announce/retry — reset one delivery leg to pending so
|
||||
// the dispatcher re-attempts it (e.g. after fixing the news channel / sidecar).
|
||||
async function retryAnnounceLeg(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
const leg = req.body.leg
|
||||
try {
|
||||
const job = await announceJobs.resetLeg(id, leg)
|
||||
if (!job) return res.status(404).json({ message: 'No announcement job for this post' })
|
||||
await activity.log({ req, action: 'post.announce.retry', detail: { id, leg } })
|
||||
return res.json(job)
|
||||
} catch (err) {
|
||||
log.error('retryAnnounceLeg', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage(req, res) {
|
||||
if (!req.file) return res.status(400).json({ message: 'No image uploaded' })
|
||||
const imageUrl = `/uploads/${req.file.filename}`
|
||||
@@ -461,6 +485,12 @@ async function updateSettings(req, res) {
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid player_registration 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).
|
||||
if (typeof updates.homepage_teaser === 'string') {
|
||||
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
|
||||
}
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
||||
@@ -594,6 +624,8 @@ module.exports = {
|
||||
updatePost,
|
||||
publishPost,
|
||||
deletePost,
|
||||
getAnnounceStatus,
|
||||
retryAnnounceLeg,
|
||||
uploadImage,
|
||||
uploadFile,
|
||||
listWiki,
|
||||
|
||||
@@ -10,7 +10,11 @@ const account = require('./account.controller')
|
||||
const botActivity = require('./botActivity.controller')
|
||||
const authProviders = require('./authProviders.controller')
|
||||
const discordBot = require('./discordBot.controller')
|
||||
const emailConfig = require('./emailConfig.controller')
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
const pagesCtrl = require('./pages.controller')
|
||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -107,6 +111,75 @@ adminRouter.delete(
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
// ── Game account linking (self-service, any staff role) ───────────────
|
||||
// Staff link their OWN in-game account here, exactly like players do under
|
||||
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
adminRouter.post(
|
||||
'/shard/link',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||
validate,
|
||||
selfShard.link,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
selfShard.listAccounts,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/roster/:account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
selfShard.roster,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/vendors/:account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
selfShard.vendors,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/char/:serial',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
selfShard.getChar,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/sales',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
selfShard.getSales,
|
||||
)
|
||||
|
||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||
const UPLOAD_DIR =
|
||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||||
@@ -275,6 +348,32 @@ adminRouter.delete(
|
||||
validate,
|
||||
ctrl.deletePost,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/posts/:id/announce',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Get the announcement pipeline status for a post'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.responses[200] = { description: 'The announce job for the post, or null if never announced', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.getAnnounceStatus,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/posts/:id/announce/retry',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('leg').isIn(['towncrier', 'discord']),
|
||||
validate,
|
||||
ctrl.retryAnnounceLeg,
|
||||
)
|
||||
|
||||
// ── Wiki categories (static paths registered before /wiki/:slug) ───────
|
||||
adminRouter.get(
|
||||
@@ -474,6 +573,99 @@ adminRouter.delete(
|
||||
ctrl.deleteWiki,
|
||||
)
|
||||
|
||||
// ── CMS Pages (block-based page builder) ──────────────────────────────
|
||||
adminRouter.get(
|
||||
'/pages',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'List all CMS pages (summaries)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
pagesCtrl.listPages,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/pages',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Create a CMS page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('slug').isString().trim().notEmpty(),
|
||||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
validate,
|
||||
pagesCtrl.createPage,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/pages/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'The page', 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,
|
||||
pagesCtrl.getPage,
|
||||
)
|
||||
adminRouter.patch(
|
||||
'/pages/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
|
||||
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.updatePage,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/pages/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.deletePage,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/pages/:id/unprotect',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
|
||||
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('password').isString().notEmpty(),
|
||||
validate,
|
||||
pagesCtrl.unprotectPage,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/pages/:id/preview',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.createPreview,
|
||||
)
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────
|
||||
adminRouter.get(
|
||||
'/settings',
|
||||
@@ -575,6 +767,84 @@ adminRouter.put(
|
||||
discordBot.saveConfig,
|
||||
)
|
||||
|
||||
// ── Email delivery (Gmail OAuth2, admin only) ─────────────────────────
|
||||
// Modern replacement for env SMTP: the refresh token is captured by the connect
|
||||
// flow and is write-only over this API (stored encrypted, never returned).
|
||||
adminRouter.get(
|
||||
'/email/config',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Get email delivery config + status (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
emailConfig.getConfig,
|
||||
)
|
||||
adminRouter.put(
|
||||
'/email/config',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Update email delivery config (admin only)'
|
||||
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
validate,
|
||||
emailConfig.saveConfig,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/email/connect/start',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
|
||||
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
emailConfig.connectStart,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/email/connect/callback',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
|
||||
adminOnly,
|
||||
emailConfig.connectCallback,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/email/test',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Send a test email (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[502] = { description: 'Send failed / not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('to').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||||
validate,
|
||||
emailConfig.testSend,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/email/disconnect',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
emailConfig.disconnect,
|
||||
)
|
||||
|
||||
// ── Authentication providers / SSO (admin only) ───────────────────────
|
||||
adminRouter.get(
|
||||
'/auth/providers',
|
||||
@@ -812,4 +1082,75 @@ adminRouter.delete(
|
||||
ctrl.deleteUser,
|
||||
)
|
||||
|
||||
// ── 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).
|
||||
adminRouter.get(
|
||||
'/uo-link/config',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
uoLink.getConfig,
|
||||
)
|
||||
adminRouter.put(
|
||||
'/uo-link/config',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Save uo-link connection config (admin only)'
|
||||
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||||
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
|
||||
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||||
body('protocol').optional().isInt({ min: 1, max: 99 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
validate,
|
||||
uoLink.saveConfig,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/uo-link/towncrier',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||
body('lines').isArray({ min: 1, max: 8 }),
|
||||
body('lines.*').isString().isLength({ max: 200 }),
|
||||
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
|
||||
validate,
|
||||
uoLink.postTownCrier,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/uo-link/towncrier/:id',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Remove a town-crier message (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
uoLink.deleteTownCrier,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/uo-link/stream',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
|
||||
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||
adminOnly,
|
||||
uoLink.stream,
|
||||
)
|
||||
|
||||
module.exports = adminRouter
|
||||
|
||||
211
server/src/router/v1/admin/emailConfig.controller.js
Normal file
211
server/src/router/v1/admin/emailConfig.controller.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// ── Admin: outbound email configuration (Gmail OAuth2) ─────────────────────
|
||||
//
|
||||
// Modern replacement for env-var SMTP. Sending goes through Gmail over OAuth2;
|
||||
// the admin connects the mailbox with an in-app consent flow that captures a
|
||||
// refresh token. We reuse the existing `google` SSO OAuth client (its id/secret)
|
||||
// rather than a second app — so the only per-mailbox secret is the refresh token,
|
||||
// stored AES-GCM-encrypted and write-only over this API (never returned).
|
||||
//
|
||||
// The connect flow mirrors sso.controller.js: a signed httpOnly tx cookie carries
|
||||
// the CSRF nonce + PKCE verifier across the redirect to Google and back. It differs
|
||||
// only in scope (https://mail.google.com/ for SMTP XOAUTH2) and access_type=offline
|
||||
// + prompt=consent, which guarantee a refresh token even on reconnect.
|
||||
|
||||
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
|
||||
const authProviders = require('../../../model/authProviders/authProviders.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
const GoogleProvider = require('../../../auth/providers/google.provider')
|
||||
const ssoState = require('../../../auth/ssoState')
|
||||
const token = require('../../../auth/token')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
|
||||
// Gmail scope grants SMTP (XOAUTH2) access; openid+email let us read back which
|
||||
// address was connected. The narrower gmail.send scope only works via the Gmail
|
||||
// API, not SMTP, so we need the full-access scope here.
|
||||
const EMAIL_SCOPE = 'https://mail.google.com/ openid email'
|
||||
const TX_COOKIE = 'email_oauth_tx'
|
||||
|
||||
// Public base URL for the OAuth redirect_uri — same fallback pattern as
|
||||
// sso.controller.js. Must be identical between start and callback.
|
||||
function appBaseUrl(req) {
|
||||
const configured = process.env.APP_BASE_URL
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
const derived = `${req.protocol}://${req.get('host')}`
|
||||
log.warn('APP_BASE_URL not set — deriving email redirect_uri from the request', { derived })
|
||||
return derived
|
||||
}
|
||||
function redirectUri(req) {
|
||||
return `${appBaseUrl(req)}/api/v1/admin/email/connect/callback`
|
||||
}
|
||||
function txCookieOptions(req) {
|
||||
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
|
||||
}
|
||||
|
||||
// Front-end redirect targets after the callback resolves.
|
||||
const CONNECTED_URL = '/admin/settings?email_connected=1'
|
||||
const errorUrl = (code) => `/admin/settings?email_error=${code}`
|
||||
|
||||
// Load the Google OAuth client (id + decrypted secret) reused for email. Returns
|
||||
// null when the google provider hasn't been configured with credentials yet.
|
||||
async function googleClient() {
|
||||
const row = await authProviders.getWithSecret('google')
|
||||
if (!row || !row.client_id || !row.client_secret) return null
|
||||
return { clientId: row.client_id, clientSecret: row.client_secret }
|
||||
}
|
||||
|
||||
// GET /admin/email/config
|
||||
async function getConfig(req, res) {
|
||||
try {
|
||||
const config = await emailConfig.getSafe()
|
||||
// Surface whether the Google client email can borrow is configured, so the
|
||||
// UI can explain why Connect is unavailable.
|
||||
config.googleConfigured = Boolean(await googleClient())
|
||||
return res.json(config)
|
||||
} catch (err) {
|
||||
log.error('emailConfig.getConfig', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/email/config — sender name + enabled toggle. Enabling requires a
|
||||
// connected mailbox (a stored refresh token).
|
||||
async function saveConfig(req, res) {
|
||||
const { senderName, enabled } = req.body
|
||||
try {
|
||||
const current = await emailConfig.getSafe()
|
||||
if (enabled && !current.hasRefreshToken) {
|
||||
return res.status(400).json({ message: 'Connect a Gmail account before enabling email.' })
|
||||
}
|
||||
const saved = await emailConfig.save({
|
||||
senderName: senderName !== undefined ? senderName || null : undefined,
|
||||
enabled,
|
||||
updatedBy: req.user.id,
|
||||
})
|
||||
saved.googleConfigured = Boolean(await googleClient())
|
||||
await activity.log({ req, action: 'email.config.update', detail: { enabled: saved.enabled } })
|
||||
log.info('email config updated', { by: req.user.username, enabled: saved.enabled })
|
||||
return res.json(saved)
|
||||
} catch (err) {
|
||||
log.error('emailConfig.saveConfig', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/email/connect/start — returns { url } for the browser to navigate to.
|
||||
async function connectStart(req, res) {
|
||||
try {
|
||||
const client = await googleClient()
|
||||
if (!client) {
|
||||
return res.status(400).json({
|
||||
message: 'Configure the Google authentication provider (client id + secret) before connecting email.',
|
||||
})
|
||||
}
|
||||
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
|
||||
const tx = ssoState.createTx({ flow: 'email' })
|
||||
res.cookie(TX_COOKIE, tx.txToken, txCookieOptions(req))
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.clientId,
|
||||
redirect_uri: redirectUri(req),
|
||||
response_type: 'code',
|
||||
scope: EMAIL_SCOPE,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
include_granted_scopes: 'true',
|
||||
state: tx.nonce,
|
||||
code_challenge: tx.codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
const url = `${provider.authEndpoint()}?${params.toString()}`
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
log.error('emailConfig.connectStart', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/email/connect/callback — exchange the code, capture the refresh
|
||||
// token + connected address, store encrypted, and redirect back to Settings.
|
||||
async function connectCallback(req, res) {
|
||||
const txToken = req.cookies && req.cookies[TX_COOKIE]
|
||||
const { code, state, error: oauthError } = req.query
|
||||
res.clearCookie(TX_COOKIE, token.cookieOptions(req)) // single-use
|
||||
|
||||
if (oauthError) {
|
||||
log.warn('email connect: provider returned error', { error: String(oauthError).slice(0, 60) })
|
||||
return res.redirect(errorUrl('denied'))
|
||||
}
|
||||
const tx = ssoState.verifyTx(txToken, state)
|
||||
if (!tx || tx.flow !== 'email' || !code) {
|
||||
log.warn('email connect: bad state')
|
||||
return res.redirect(errorUrl('bad_state'))
|
||||
}
|
||||
try {
|
||||
const client = await googleClient()
|
||||
if (!client) return res.redirect(errorUrl('no_client'))
|
||||
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
|
||||
|
||||
const tokenSet = await provider.exchangeCode({
|
||||
code,
|
||||
redirectUri: redirectUri(req),
|
||||
codeVerifier: tx.verifier,
|
||||
})
|
||||
if (!tokenSet.refresh_token) {
|
||||
// Google only returns a refresh token when it hasn't already granted one
|
||||
// for this client+scope. prompt=consent should force it; if it's still
|
||||
// missing the admin can revoke the app's access and retry.
|
||||
log.warn('email connect: no refresh_token returned')
|
||||
return res.redirect(errorUrl('no_refresh_token'))
|
||||
}
|
||||
const profile = await provider.getUserProfile(tokenSet.access_token)
|
||||
const senderEmail = profile.email || null
|
||||
if (!senderEmail) return res.redirect(errorUrl('no_email'))
|
||||
|
||||
await emailConfig.save({
|
||||
senderEmail,
|
||||
refreshToken: tokenSet.refresh_token,
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusDetail: 'Connected',
|
||||
updatedBy: req.user.id,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Connected', lastVerifiedAt: new Date() })
|
||||
await activity.log({ req, action: 'email.connect', detail: { senderEmail } })
|
||||
log.info('email connected', { senderEmail, by: req.user.username })
|
||||
return res.redirect(CONNECTED_URL)
|
||||
} catch (err) {
|
||||
log.error('emailConfig.connectCallback', err)
|
||||
return res.redirect(errorUrl('error'))
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/email/test — send a test message (to the given address, or the
|
||||
// contact recipient by default).
|
||||
async function testSend(req, res) {
|
||||
try {
|
||||
const result = await mailer.sendTest(req.body.to)
|
||||
await activity.log({ req, action: 'email.test', detail: { to: result.to } })
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.warn('email test send failed', { message: err.message })
|
||||
return res.status(502).json({ message: err.message || 'Could not send the test email.' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/email/disconnect — clear the stored credential and disable sending.
|
||||
async function disconnect(req, res) {
|
||||
try {
|
||||
const config = await emailConfig.disconnect(req.user.id)
|
||||
config.googleConfigured = Boolean(await googleClient())
|
||||
await activity.log({ req, action: 'email.disconnect' })
|
||||
log.info('email disconnected', { by: req.user.username })
|
||||
return res.json(config)
|
||||
} catch (err) {
|
||||
log.error('emailConfig.disconnect', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getConfig, saveConfig, connectStart, connectCallback, testSend, disconnect }
|
||||
131
server/src/router/v1/admin/pages.controller.js
Normal file
131
server/src/router/v1/admin/pages.controller.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// Admin CMS pages controller. Thin HTTP layer over pages.model — it translates
|
||||
// the model's PageError (status + code) into responses and records audit-log
|
||||
// entries for the lifecycle events the spec calls out (create / publish /
|
||||
// unpublish / delete, protect on, and the password-gated unprotect).
|
||||
|
||||
const pages = require('../../../model/pages/pages.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const token = require('../../../auth/token')
|
||||
const logger = require('../../../utils/logger')('pages')
|
||||
|
||||
// Map a thrown error to a response. Known PageErrors carry a status + code (and
|
||||
// sometimes a block-error list); anything else is an unexpected 500.
|
||||
function fail(res, err) {
|
||||
if (err && err.name === 'PageError') {
|
||||
const body = { message: err.message, code: err.code }
|
||||
if (err.errors) body.details = err.errors
|
||||
return res.status(err.status).json(body)
|
||||
}
|
||||
logger.error('unexpected pages error', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal error' })
|
||||
}
|
||||
|
||||
async function listPages(req, res) {
|
||||
return res.json(await pages.list())
|
||||
}
|
||||
|
||||
async function getPage(req, res) {
|
||||
const page = await pages.getById(Number(req.params.id))
|
||||
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
|
||||
return res.json(page)
|
||||
}
|
||||
|
||||
async function createPage(req, res) {
|
||||
try {
|
||||
const page = await pages.create(req.body, req.user.id)
|
||||
await activity.log({ req, action: 'page.create', detail: { id: page.id, slug: page.slug } })
|
||||
if (page.status === 'published') {
|
||||
await activity.log({ req, action: 'page.publish', detail: { id: page.id, slug: page.slug } })
|
||||
}
|
||||
return res.status(201).json(page)
|
||||
} catch (err) {
|
||||
return fail(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePage(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const before = await pages.getRawById(id)
|
||||
if (!before) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
|
||||
|
||||
const page = await pages.update(id, req.body)
|
||||
await activity.log({ req, action: 'page.update', detail: { id, slug: page.slug } })
|
||||
|
||||
// Emit dedicated audit events for the transitions the spec singles out.
|
||||
if (before.status !== page.status) {
|
||||
const action = page.status === 'published' ? 'page.publish' : 'page.unpublish'
|
||||
await activity.log({ req, action, detail: { id, slug: page.slug } })
|
||||
}
|
||||
if (!before.protected && page.settings.protected) {
|
||||
await activity.log({ req, action: 'page.protect', detail: { id, slug: page.slug } })
|
||||
}
|
||||
return res.json(page)
|
||||
} catch (err) {
|
||||
return fail(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePage(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const result = await pages.remove(id)
|
||||
await activity.log({ req, action: 'page.delete', detail: { id } })
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
return fail(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step-up auth: verify the CURRENT admin's password against their own hash
|
||||
// (independent of JWT validity) before flipping protected → false. On failure:
|
||||
// no mutation, standard 401, and the entered password is never logged anywhere.
|
||||
async function unprotectPage(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const password = req.body?.password
|
||||
if (typeof password !== 'string' || password === '') {
|
||||
return res.status(400).json({ message: 'Password is required', code: 'password_required' })
|
||||
}
|
||||
const user = await users.getRawById(req.user.id)
|
||||
const ok = await users.validatePassword(user, password)
|
||||
if (!ok) {
|
||||
logger.warn('failed page unprotect (bad password)', { pageId: id, userId: req.user.id })
|
||||
return res.status(401).json({ message: 'Password is incorrect', code: 'bad_password' })
|
||||
}
|
||||
const page = await pages.unprotect(id)
|
||||
await activity.log({ req, action: 'page.unprotect', detail: { id, slug: page.slug } })
|
||||
return res.json(page)
|
||||
} catch (err) {
|
||||
return fail(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mint a 1h preview token for the page's current (possibly unpublished) state.
|
||||
// Returns the token plus the ready-to-use public preview path.
|
||||
async function createPreview(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const page = await pages.getById(id)
|
||||
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
|
||||
const t = token.signPagePreview(id)
|
||||
return res.json({
|
||||
token: t,
|
||||
expiresInSeconds: 3600,
|
||||
path: `/api/v1/public/pages/${id}/preview/${t}`,
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listPages,
|
||||
getPage,
|
||||
createPage,
|
||||
updatePage,
|
||||
deletePage,
|
||||
unprotectPage,
|
||||
createPreview,
|
||||
}
|
||||
123
server/src/router/v1/admin/uoLink.controller.js
Normal file
123
server/src/router/v1/admin/uoLink.controller.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// ── Admin: uo-link sidecar control ─────────────────────────────────────────
|
||||
//
|
||||
// Configure the connection to the uo-link sidecar (base/ws URL, shared-secret
|
||||
// token, protocol pin, enabled) and drive the town crier. SECURITY: the token
|
||||
// is write-only over this API — stored encrypted, NEVER returned; responses
|
||||
// expose only `hasToken` (same convention as the Discord bot token). Saving
|
||||
// (re)starts the WS ingest client so a change takes effect with no redeploy.
|
||||
|
||||
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const uoLinkSocket = require('../../../utils/uoLinkSocket')
|
||||
const shardBroadcast = require('../../../utils/shardBroadcast')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-uolink')
|
||||
|
||||
// Assemble the masked config + live health + ingestion stats for the panel.
|
||||
async function buildStatus() {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
const health = await uoLinkClient.health()
|
||||
return {
|
||||
...config,
|
||||
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
|
||||
ingest: uoLinkSocket.getState(),
|
||||
sse: shardBroadcast.stats(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/uo-link/config — masked config + live status + ingestion stats.
|
||||
async function getConfig(req, res) {
|
||||
try {
|
||||
return res.json(await buildStatus())
|
||||
} catch (err) {
|
||||
log.error('uoLink.getConfig', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/uo-link/config — save connection settings + (re)start the socket.
|
||||
async function saveConfig(req, res) {
|
||||
const { baseUrl, wsUrl, token, protocol, enabled } = req.body
|
||||
try {
|
||||
const current = await uoLinkConfig.getSafe()
|
||||
const willHaveToken = Boolean(token) || current.hasToken
|
||||
if (enabled && !willHaveToken) {
|
||||
return res.status(400).json({ message: 'An auth token is required before enabling.' })
|
||||
}
|
||||
|
||||
await uoLinkConfig.save({
|
||||
baseUrl,
|
||||
wsUrl,
|
||||
token,
|
||||
protocol: protocol !== undefined ? Number(protocol) : undefined,
|
||||
enabled,
|
||||
updatedBy: req.user.id,
|
||||
})
|
||||
// Drop the client's cached config so the health check below uses the new values.
|
||||
uoLinkClient.invalidateConfig()
|
||||
|
||||
// (Re)start or stop the ingest socket to match the new enabled/URL/token.
|
||||
const saved = await uoLinkConfig.getSafe()
|
||||
if (saved.enabled && saved.hasToken) {
|
||||
await uoLinkSocket.start()
|
||||
} else {
|
||||
uoLinkSocket.stop()
|
||||
await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false })
|
||||
}
|
||||
|
||||
await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } })
|
||||
log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled })
|
||||
return res.json(await buildStatus())
|
||||
} catch (err) {
|
||||
log.error('uoLink.saveConfig', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/uo-link/towncrier — publish/replace a town-crier message.
|
||||
async function postTownCrier(req, res) {
|
||||
const { id, lines, durationSec } = req.body
|
||||
try {
|
||||
const result = await uoLinkClient.postTownCrier({ id, lines, durationSec })
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } })
|
||||
return res.json(result.data || { ok: true, id })
|
||||
}
|
||||
if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error('uoLink.postTownCrier', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/uo-link/towncrier/:id — remove a town-crier message.
|
||||
async function deleteTownCrier(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
const result = await uoLinkClient.deleteTownCrier(id)
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } })
|
||||
return res.json(result.data || { ok: true, id })
|
||||
}
|
||||
if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error('uoLink.deleteTownCrier', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only.
|
||||
function stream(req, res) {
|
||||
shardBroadcast.subscribe(req, res, 'admin')
|
||||
}
|
||||
|
||||
module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream }
|
||||
@@ -10,6 +10,7 @@ const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const account = require('../admin/account.controller')
|
||||
const shard = require('./shard.controller')
|
||||
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -129,4 +130,78 @@ playerRouter.delete(
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
// ── Game account linking (uo-link) ─────────────────────────────────────────
|
||||
// Link an in-game account with a one-time code from [link, then read the
|
||||
// account's roster / vendors (ownership-checked against the local link mirror).
|
||||
const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
playerRouter.post(
|
||||
'/shard/link',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Link an in-game account with a one-time code'
|
||||
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||
validate,
|
||||
shard.link,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/accounts',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'List the caller’s linked game accounts'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
shard.listAccounts,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/roster/:account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Character roster for a linked account'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(ACCOUNT_RE),
|
||||
validate,
|
||||
shard.roster,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/vendors/:account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Player vendors for a linked account'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(ACCOUNT_RE),
|
||||
validate,
|
||||
shard.vendors,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/char/:serial',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Character sheet — only for a character on the caller’s linked account'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shard.getChar,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/sales',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
shard.getSales,
|
||||
)
|
||||
|
||||
module.exports = playerRouter
|
||||
|
||||
144
server/src/router/v1/player/shard.controller.js
Normal file
144
server/src/router/v1/player/shard.controller.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// ── Player: game-account linking + reads ───────────────────────────────────
|
||||
//
|
||||
// The player-facing surface for the uo-link integration. A logged-in player
|
||||
// runs [link in game, gets a one-time code, and enters it here — the server
|
||||
// confirms it with the sidecar (which permanently tags the game account with the
|
||||
// website user id) and mirrors the link locally. Roster/vendor reads are
|
||||
// ownership-checked against that mirror so a player can only see accounts they
|
||||
// have linked. The sidecar token stays server-side throughout.
|
||||
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('player-shard')
|
||||
|
||||
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||
|
||||
// POST /player/shard/link — confirm an in-game link code.
|
||||
async function link(req, res) {
|
||||
const { code } = req.body
|
||||
try {
|
||||
const result = await uoLinkClient.confirmLink(code, req.user.id)
|
||||
|
||||
if (result.ok && result.data && result.data.kind === 'link.ok') {
|
||||
const account = result.data.account
|
||||
await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null })
|
||||
await activity.log({ req, action: 'uoLink.account.link', detail: { account } })
|
||||
log.info('player linked game account', { user: req.user.username, account })
|
||||
return res.json({ linked: true, account })
|
||||
}
|
||||
|
||||
// Sidecar reports bad/expired codes as 400 link.error or 404.
|
||||
if (result.status === 400 || result.status === 404) {
|
||||
return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' })
|
||||
}
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not confirm the link with the shard.' })
|
||||
} catch (err) {
|
||||
log.error('player.shard.link', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/shard/accounts — the caller's linked game accounts.
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
return res.json(await shardLinks.listForUser(req.user.id))
|
||||
} catch (err) {
|
||||
log.error('player.shard.listAccounts', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Admins may view any character's data; everyone else is limited to accounts
|
||||
// they have personally linked. The same handlers back /player/shard (role
|
||||
// `player`, never admin) and /admin/shard (staff), so this bypass only ever
|
||||
// widens access for genuine admins.
|
||||
const isAdmin = (req) => req.user && req.user.role === 'admin'
|
||||
|
||||
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
|
||||
// uoLinkClient method to call with the account.
|
||||
async function ownedRoundTrip(req, res, fetcher, label) {
|
||||
const { account } = req.params
|
||||
try {
|
||||
const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id))
|
||||
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
|
||||
|
||||
const result = await fetcher(account)
|
||||
if (result.ok) return res.json(result.data)
|
||||
if (result.status === 404) return res.status(404).json({ message: 'Not found.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error(`player.shard.${label}`, err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/shard/roster/:account — characters on a linked account.
|
||||
const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster')
|
||||
|
||||
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
||||
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
|
||||
|
||||
// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's
|
||||
// account is linked to the caller. The sidecar returns the owning account in the
|
||||
// profile, which we check against the caller's links before returning anything.
|
||||
async function getChar(req, res) {
|
||||
const { serial } = req.params
|
||||
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' })
|
||||
try {
|
||||
const result = await uoLinkClient.getCharBySerial(serial)
|
||||
if (result.ok) {
|
||||
// Admins see any character; others only characters on an account they linked.
|
||||
if (!isAdmin(req)) {
|
||||
const acct = result.data && result.data.acct
|
||||
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)
|
||||
}
|
||||
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error('player.shard.getChar', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/shard/sales — recent player-vendor sales for the caller's linked
|
||||
// accounts only (as seller/owner). Read from the site's own event log.
|
||||
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)
|
||||
} 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 }
|
||||
@@ -1,10 +1,21 @@
|
||||
const posts = require('../../../model/posts/posts.model')
|
||||
const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const pages = require('../../../model/pages/pages.model')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
const { getUserFromRequest } = require('../../../utils/auth')
|
||||
const token = require('../../../auth/token')
|
||||
|
||||
const log = require('../../../utils/logger')('public')
|
||||
|
||||
// Staff (non-player) roles may see draft pages on the public route; everyone else
|
||||
// gets a 404 for a draft, indistinguishable from a missing page.
|
||||
const STAFF_ROLES = ['admin', 'editor', 'moderator']
|
||||
function isStaff(req) {
|
||||
const user = getUserFromRequest(req)
|
||||
return Boolean(user && STAFF_ROLES.includes(user.role))
|
||||
}
|
||||
|
||||
async function getSettings(req, res) {
|
||||
try {
|
||||
return res.json(await settings.getPublic())
|
||||
@@ -100,6 +111,34 @@ async function getWikiPage(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getPage(req, res) {
|
||||
try {
|
||||
// Staff see drafts (live preview); the public sees published pages only.
|
||||
const page = await pages.getBySlug(req.params.slug, { includeUnpublished: isStaff(req) })
|
||||
if (!page) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(page)
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Token-gated draft preview: renders the page's current block state regardless of
|
||||
// status, for anyone holding the (short-lived, unguessable) link.
|
||||
async function getPagePreview(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const decoded = token.verifyPagePreview(req.params.token)
|
||||
if (!decoded || decoded.pageId !== id) {
|
||||
return res.status(404).json({ message: 'Preview not found or expired' })
|
||||
}
|
||||
const page = await pages.getById(id)
|
||||
if (!page) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(page)
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function contact(req, res) {
|
||||
const { name, email, message } = req.body
|
||||
try {
|
||||
@@ -120,5 +159,7 @@ module.exports = {
|
||||
getWikiTags,
|
||||
getWikiList,
|
||||
getWikiPage,
|
||||
getPage,
|
||||
getPagePreview,
|
||||
contact,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const ctrl = require('./public.controller')
|
||||
const shard = require('./shard.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { contactLimiter } = require('../../../middleware/rateLimit')
|
||||
@@ -102,4 +103,86 @@ publicRouter.get(
|
||||
ctrl.getWikiPage,
|
||||
)
|
||||
|
||||
// ── CMS pages (block-based) ────────────────────────────────────────────
|
||||
// Preview is registered before /pages/:slug and is NOT site-mode gated, so a
|
||||
// draft-preview link keeps working during maintenance. The token itself is the
|
||||
// access control.
|
||||
publicRouter.get(
|
||||
'/pages/:id/preview/:token',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Render a page from a draft-preview token'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Preview token from POST /admin/pages/:id/preview.' }
|
||||
/* #swagger.responses[200] = { description: 'The page (any status)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Token invalid/expired or page missing', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getPagePreview,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/pages/:slug',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Get a published CMS page by slug'
|
||||
// #swagger.description = 'Drafts 404 for the public; staff sessions see drafts. Gated by site mode.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getPage,
|
||||
)
|
||||
|
||||
// ── Shard live data (uo-link) ──────────────────────────────────────────────
|
||||
// Token-free, same-origin reads. The status/feed/economy/idoc endpoints read
|
||||
// the site's own ingested data; /char round-trips the live shard (cached). Not
|
||||
// site-mode gated — shard status is useful even during site maintenance.
|
||||
publicRouter.get(
|
||||
'/shard/status',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
||||
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
||||
shard.getStatus,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/feed',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
|
||||
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
shard.getFeed,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/economy',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
|
||||
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */
|
||||
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
shard.getEconomy,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/online',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)'
|
||||
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||
shard.getOnline,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/idoc',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
||||
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getIdoc,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/stream',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)'
|
||||
// #swagger.description = 'text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.'
|
||||
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||
shard.stream,
|
||||
)
|
||||
|
||||
module.exports = publicRouter
|
||||
|
||||
100
server/src/router/v1/public/shard.controller.js
Normal file
100
server/src/router/v1/public/shard.controller.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── Public: shard live data ────────────────────────────────────────────────
|
||||
//
|
||||
// Same-origin, token-free read endpoints backed by the data the WS ingest
|
||||
// pipeline persists (shard_online / shard_events / shard_economy / shard_houses)
|
||||
// plus a live character round-trip to the sidecar. The browser never sees the
|
||||
// sidecar URL or token — every sidecar call is server-side (uoLinkClient).
|
||||
//
|
||||
// The stored-data endpoints are cheap DB reads. The live /char endpoint hits the
|
||||
// running shard, so it is briefly cached and degrades gracefully: a 503 (shard
|
||||
// restarting) surfaces as a retry-able banner rather than an error.
|
||||
|
||||
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcast = require('../../../utils/shardBroadcast')
|
||||
|
||||
const log = require('../../../utils/logger')('public-shard')
|
||||
|
||||
// GET /public/shard/status — connection state + online count + latest economy.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
const [online, economy] = await Promise.all([
|
||||
shardState.onlineCount(),
|
||||
shardState.latestEconomy(),
|
||||
])
|
||||
return res.json({
|
||||
enabled: config.enabled,
|
||||
status: config.status,
|
||||
pluginConnected: config.pluginConnected,
|
||||
lastEventAt: config.lastEventAt,
|
||||
onlineCount: online,
|
||||
economy,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('shard.getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/feed?kind=&limit= — recent notable events from the log,
|
||||
// restricted to the public-safe allowlist so staff audit / cheat / link events
|
||||
// (which are stored for the admin channel) can never leak to the public.
|
||||
async function getFeed(req, res) {
|
||||
try {
|
||||
const { kind, limit } = req.query
|
||||
let events
|
||||
if (kind) {
|
||||
// A specific kind is only served if it is itself public-safe.
|
||||
if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([])
|
||||
events = await shardEvents.list({ kind, limit })
|
||||
} else {
|
||||
events = await shardEvents.list({ kinds: [...broadcast.PUBLIC_KINDS], limit })
|
||||
}
|
||||
return res.json(events)
|
||||
} catch (err) {
|
||||
log.error('shard.getFeed', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/economy — gold-supply series, oldest → newest.
|
||||
async function getEconomy(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listEconomy(req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('shard.getEconomy', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/online — players online now whose account is linked to a
|
||||
// STAFF website user (admin/editor/moderator). Shows name + location (map +
|
||||
// coordinates); no vitals or account. Non-staff players are never listed.
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
const rows = await shardState.listOnlineLinked()
|
||||
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map, x: r.x, y: r.y, z: r.z })))
|
||||
} catch (err) {
|
||||
log.error('shard.getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
||||
async function getIdoc(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listIdoc())
|
||||
} catch (err) {
|
||||
log.error('shard.getIdoc', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/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 }
|
||||
@@ -4,11 +4,15 @@ const http = require('http')
|
||||
const app = require('./app')
|
||||
const internalApp = require('./internalApp')
|
||||
const botScore = require('./middleware/botScore')
|
||||
const uoLinkSocket = require('./utils/uoLinkSocket')
|
||||
const uoLinkClient = require('./utils/uoLinkClient')
|
||||
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
||||
const shardBroadcast = require('./utils/shardBroadcast')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||
const mailer = require('./utils/mailer')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||
const pkg = require('../package.json')
|
||||
@@ -30,7 +34,7 @@ async function start() {
|
||||
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'}`,
|
||||
cookieSecure: process.env.COOKIE_SECURE || 'auto',
|
||||
smtp: mailer.isConfigured() ? 'configured' : 'not configured (mailto fallback)',
|
||||
email: 'gmail-oauth2 (configured in admin → settings)',
|
||||
})
|
||||
|
||||
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
|
||||
@@ -78,9 +82,52 @@ async function start() {
|
||||
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
|
||||
})
|
||||
|
||||
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
|
||||
// connects when the admin has enabled the integration and saved a token, so
|
||||
// this is a no-op on shards that haven't configured the sidecar. Never let a
|
||||
// sidecar problem block server startup.
|
||||
try {
|
||||
await uoLinkSocket.start()
|
||||
await checkUoLink()
|
||||
} catch (err) {
|
||||
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
||||
}
|
||||
|
||||
// Start the news-announcement dispatcher: a light in-process poller that pushes
|
||||
// published news posts to the in-game town crier + Discord with independent
|
||||
// retry per leg. No-op until a news post is actually published.
|
||||
announceWorker.start()
|
||||
|
||||
setupShutdown(server, internalServer)
|
||||
}
|
||||
|
||||
// Best-effort startup probe of the uo-link sidecar: if the integration is
|
||||
// enabled, log whether it is reachable and warn loudly on a protocol mismatch
|
||||
// (fail-fast visibility rather than silently mis-parsing a newer wire format).
|
||||
async function checkUoLink() {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
if (!config.enabled) return
|
||||
const health = await uoLinkClient.health()
|
||||
if (!health.ok) {
|
||||
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
|
||||
baseUrl: config.baseUrl,
|
||||
error: health.error || `status ${health.status}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
|
||||
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
|
||||
pinned: config.protocol,
|
||||
sidecar: health.data.protocol,
|
||||
})
|
||||
} else {
|
||||
log.info('uo-link sidecar reachable', {
|
||||
pluginConnected: health.data && health.data.plugin_connected,
|
||||
protocol: health.data && health.data.protocol,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setupShutdown(server, internalServer) {
|
||||
let closing = false
|
||||
const shutdown = async (signal) => {
|
||||
@@ -88,6 +135,9 @@ function setupShutdown(server, internalServer) {
|
||||
closing = true
|
||||
log.warn(`${signal} received — shutting down gracefully`)
|
||||
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||
uoLinkSocket.stop() // close the uo-link WS ingest client
|
||||
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
||||
server.close(() => log.info('http server closed'))
|
||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||
try {
|
||||
|
||||
132
server/src/utils/announceWorker.js
Normal file
132
server/src/utils/announceWorker.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// ── Announcement dispatcher worker ──────────────────────────────────────────
|
||||
//
|
||||
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every
|
||||
// ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
|
||||
// enqueued or past their backoff — and dispatches each one:
|
||||
// • town crier → uoLinkClient.postTownCrier (sidecar → in-game)
|
||||
// • discord → botInternalClient.announce (bot → #news channel)
|
||||
// Both clients never throw (they return { ok, status, error }); the model turns
|
||||
// each result into done / retry / terminal and owns the backoff + rollup. One
|
||||
// leg failing never touches the other. Same setInterval + unref + stop() shape
|
||||
// as middleware/botScore's sweeper, wired into server.js start/shutdown.
|
||||
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
const announceJobsDb = require('../model/announceJobs/announceJobs.db')
|
||||
const logic = require('../model/announceJobs/announceJobs.logic')
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const botInternalClient = require('./botInternalClient')
|
||||
const log = require('./logger')('announce-worker')
|
||||
|
||||
const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000
|
||||
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// ── Leg dispatchers ─────────────────────────────────────────────────────────
|
||||
// Return the raw client result ({ ok, status, data, error }); classification is
|
||||
// the model/logic's job.
|
||||
|
||||
async function dispatchTownCrier(post) {
|
||||
const lines = logic.buildTownCrierText(post, { baseUrl: baseUrl() })
|
||||
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
|
||||
// than stacking a duplicate, so a retry after a partial failure is safe.
|
||||
return uoLinkClient.postTownCrier({
|
||||
id: `post-${post.id}`,
|
||||
lines,
|
||||
durationSec: TOWNCRIER_DURATION_SEC,
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchDiscord(post) {
|
||||
const base = baseUrl()
|
||||
// Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
|
||||
// absolute URL.
|
||||
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
|
||||
return botInternalClient.announce({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${base}/site/news`,
|
||||
imageUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// Process a single due leg of a job: fetch the post, dispatch, classify, record.
|
||||
async function processLeg(job, leg) {
|
||||
const post = await posts.getById(job.post_id)
|
||||
if (!post) {
|
||||
// Post was deleted between enqueue and dispatch (the CASCADE usually reaps
|
||||
// the job first, but guard anyway). Nothing to announce — fail the leg.
|
||||
await announceJobs.recordOutcome(job, leg, { outcome: 'terminal', error: 'post no longer exists' })
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
let classification
|
||||
try {
|
||||
if (leg === 'towncrier') {
|
||||
result = await dispatchTownCrier(post)
|
||||
classification = logic.classifyTownCrier(result)
|
||||
} else {
|
||||
result = await dispatchDiscord(post)
|
||||
classification = logic.classifyDiscord(result)
|
||||
}
|
||||
} catch (err) {
|
||||
// Clients shouldn't throw, but if one does, treat it as a transient failure
|
||||
// rather than crashing the tick.
|
||||
log.error('dispatch threw', { jobId: job.id, leg, message: err.message })
|
||||
classification = { outcome: 'retry', error: err.message }
|
||||
}
|
||||
|
||||
await announceJobs.recordOutcome(job, leg, classification)
|
||||
}
|
||||
|
||||
// One sweep: find due jobs and process each due leg. A job may have both legs due
|
||||
// (a fresh enqueue) — process the ones that are actually pending. `job` is a
|
||||
// snapshot from the SELECT; recordOutcome re-reads for the rollup, so processing
|
||||
// the two legs sequentially off the same snapshot is fine (each leg only writes
|
||||
// its own columns).
|
||||
async function tick(now = new Date()) {
|
||||
let jobs
|
||||
try {
|
||||
jobs = await announceJobsDb.findDue(now)
|
||||
} catch (err) {
|
||||
log.error('failed to load due jobs', { message: err.message })
|
||||
return
|
||||
}
|
||||
if (!jobs || jobs.length === 0) return
|
||||
|
||||
for (const job of jobs) {
|
||||
if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier')
|
||||
if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord')
|
||||
}
|
||||
}
|
||||
|
||||
function isLegDue(job, leg, now) {
|
||||
if (job[`${leg}_status`] !== 'pending') return false
|
||||
const next = job[`${leg}_next_attempt_at`]
|
||||
return next == null || new Date(next) <= now
|
||||
}
|
||||
|
||||
let timer = null
|
||||
|
||||
function start() {
|
||||
if (timer) return timer
|
||||
timer = setInterval(() => {
|
||||
tick().catch((err) => log.error('announce tick failed', { message: err.message }))
|
||||
}, POLL_MS)
|
||||
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
|
||||
log.info('announcement dispatcher started', { pollMs: POLL_MS })
|
||||
return timer
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord }
|
||||
@@ -24,12 +24,21 @@ async function call(path, { method = 'GET', body } = {}) {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `bot responded ${res.status}` }
|
||||
// Include the numeric status + parsed body (if any) so callers — e.g. the
|
||||
// announcement worker — can distinguish 503 (bot down, retry) from a config
|
||||
// error. Non-JSON bodies just leave `data` null.
|
||||
let data = null
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch {
|
||||
// ignore — body already reported via status
|
||||
}
|
||||
return { ok: false, status: res.status, data, error: `bot responded ${res.status}` }
|
||||
}
|
||||
return { ok: true, data: await res.json() }
|
||||
return { ok: true, status: res.status, data: await res.json() }
|
||||
} catch (err) {
|
||||
log.warn('bot internal call failed', { path, message: err.message })
|
||||
return { ok: false, error: err.message }
|
||||
return { ok: false, status: 0, error: err.message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,125 @@
|
||||
// ── Outbound mail (Gmail over OAuth2 / SMTP XOAUTH2) ───────────────────────
|
||||
//
|
||||
// Email is configured in Admin → Settings → Email, not via env vars. The
|
||||
// connection (enabled flag, connected Gmail address, encrypted refresh token)
|
||||
// lives in the email_config singleton; the OAuth client id/secret are reused
|
||||
// from the `google` auth_providers row. nodemailer takes the refresh token and
|
||||
// auto-mints short-lived access tokens for each send.
|
||||
//
|
||||
// When email is not configured, sendContactMessage does NOT throw — it signals
|
||||
// the caller to fall back to a mailto: link (the contact form relies on this).
|
||||
|
||||
const nodemailer = require('nodemailer')
|
||||
require('dotenv').config()
|
||||
|
||||
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, CONTACT_TO } = process.env
|
||||
const emailConfig = require('../model/emailConfig/emailConfig.model')
|
||||
const authProviders = require('../model/authProviders/authProviders.model')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const log = require('./logger')('mailer')
|
||||
|
||||
function isConfigured() {
|
||||
return Boolean(SMTP_HOST && CONTACT_TO)
|
||||
// Ready to send only when enabled, connected (has a refresh token), and we know
|
||||
// which address to send as.
|
||||
async function isConfigured() {
|
||||
const c = await emailConfig.getSafe()
|
||||
return Boolean(c.enabled && c.hasRefreshToken && c.senderEmail)
|
||||
}
|
||||
|
||||
let transporter = null
|
||||
function getTransporter() {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: Number(SMTP_PORT) || 587,
|
||||
secure: Number(SMTP_PORT) === 465,
|
||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
})
|
||||
// Recipient for the contact form: the admin-editable contact_email setting, or
|
||||
// the connected sending address as a last resort.
|
||||
async function contactRecipient(senderEmail) {
|
||||
const to = await settings.get('contact_email')
|
||||
return to || senderEmail || null
|
||||
}
|
||||
|
||||
// Build a nodemailer OAuth2 transport from the stored config + reused Google
|
||||
// client credentials. Returns { transport, config } or null when unconfigured.
|
||||
async function buildTransport() {
|
||||
const config = await emailConfig.getWithSecret()
|
||||
if (!config || !config.refreshToken || !config.senderEmail) return null
|
||||
const google = await authProviders.getWithSecret('google')
|
||||
if (!google || !google.client_id || !google.client_secret) {
|
||||
log.warn('email send skipped: Google OAuth client is not configured')
|
||||
return null
|
||||
}
|
||||
return transporter
|
||||
const transport = nodemailer.createTransport({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
type: 'OAuth2',
|
||||
user: config.senderEmail,
|
||||
clientId: google.client_id,
|
||||
clientSecret: google.client_secret,
|
||||
refreshToken: config.refreshToken,
|
||||
},
|
||||
})
|
||||
return { transport, config }
|
||||
}
|
||||
|
||||
function fromHeader(config) {
|
||||
return config.senderName ? `"${config.senderName}" <${config.senderEmail}>` : config.senderEmail
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a contact message. If SMTP is not configured, signals the caller to fall
|
||||
* back to a mailto: link instead of throwing. Credentials come from env only.
|
||||
* Send a contact message. If email is not configured/enabled, signals the caller
|
||||
* to fall back to a mailto: link instead of throwing.
|
||||
*/
|
||||
async function sendContactMessage({ name, email, message }) {
|
||||
if (!isConfigured()) {
|
||||
return { sent: false, fallback: 'mailto', email: CONTACT_TO || null }
|
||||
const built = await buildTransport()
|
||||
if (!built) {
|
||||
const c = await emailConfig.getSafe()
|
||||
return { sent: false, fallback: 'mailto', email: await contactRecipient(c.senderEmail) }
|
||||
}
|
||||
const { transport, config } = built
|
||||
const to = await contactRecipient(config.senderEmail)
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: email,
|
||||
subject: `UOMysticmoon 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() })
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('contact send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
throw err
|
||||
}
|
||||
await getTransporter().sendMail({
|
||||
from: SMTP_USER || CONTACT_TO,
|
||||
to: CONTACT_TO,
|
||||
replyTo: email,
|
||||
subject: `UOMysticmoon contact from ${name || 'a visitor'}`,
|
||||
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
|
||||
})
|
||||
return { sent: true }
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage }
|
||||
/**
|
||||
* Send a test email to `to`, used by the admin "Send test" button. Throws on
|
||||
* failure; records the outcome either way. Returns { sent: true } on success.
|
||||
*/
|
||||
async function sendTest(to) {
|
||||
const built = await buildTransport()
|
||||
if (!built) {
|
||||
const err = new Error('Email is not configured. Connect Gmail first.')
|
||||
err.code = 'NOT_CONFIGURED'
|
||||
throw err
|
||||
}
|
||||
const { transport, config } = built
|
||||
const recipient = to || (await contactRecipient(config.senderEmail))
|
||||
if (!recipient) {
|
||||
const err = new Error('No recipient available for the test email.')
|
||||
err.code = 'NO_RECIPIENT'
|
||||
throw err
|
||||
}
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to: recipient,
|
||||
subject: 'UOMysticmoon 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() })
|
||||
return { sent: true, to: recipient }
|
||||
} catch (err) {
|
||||
log.error('test send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest }
|
||||
|
||||
@@ -18,6 +18,19 @@ const OPTIONS = {
|
||||
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
|
||||
th: ['colspan', 'rowspan'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
// Block alignment from the rich-text editor. `style` is only honored for the
|
||||
// properties/values whitelisted in allowedStyles below — everything else in
|
||||
// the style attribute is stripped.
|
||||
p: ['style'],
|
||||
h1: ['style'], h2: ['style'], h3: ['style'],
|
||||
h4: ['style'], h5: ['style'], h6: ['style'],
|
||||
},
|
||||
// Restrict inline styles to text-align (left/right/center/justify) only. Any
|
||||
// other CSS property, or an unlisted value, is discarded.
|
||||
allowedStyles: {
|
||||
'*': {
|
||||
'text-align': [/^(left|right|center|justify)$/],
|
||||
},
|
||||
},
|
||||
// http/https for links and images, mailto for links, plus relative URLs so
|
||||
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
|
||||
|
||||
117
server/src/utils/shardBroadcast.js
Normal file
117
server/src/utils/shardBroadcast.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
|
||||
//
|
||||
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
||||
// never reach it, and the WS may be on another host). Instead the server ingests
|
||||
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
|
||||
// Events (plain HTTP — works through any reverse proxy).
|
||||
//
|
||||
// Two channels:
|
||||
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
|
||||
// no account-login attempts, no staff audit / cheat events.
|
||||
// • admin — everything, including the sensitive kinds above.
|
||||
//
|
||||
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
||||
// SSE route handlers call subscribe(req, res, channel).
|
||||
|
||||
const log = require('./logger')('shard-broadcast')
|
||||
|
||||
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
|
||||
// deliberately NOT here — sales are owner-private (a linked player sees only
|
||||
// their own, via /player/shard/sales).
|
||||
const PUBLIC_KINDS = new Set([
|
||||
'player.death',
|
||||
'player.murdered',
|
||||
'mob.killed',
|
||||
'house.decay',
|
||||
'quest.complete',
|
||||
'skill.gain',
|
||||
'fame.change',
|
||||
'karma.change',
|
||||
'mob.login',
|
||||
'mob.logout',
|
||||
'economy.supply',
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
])
|
||||
|
||||
// Open response streams per channel.
|
||||
const clients = { public: new Set(), admin: new Set() }
|
||||
|
||||
const KEEPALIVE_MS = 25000
|
||||
|
||||
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
||||
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
||||
function subscribe(req, res, channel) {
|
||||
const bucket = clients[channel]
|
||||
if (!bucket) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
|
||||
})
|
||||
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
||||
res.write(': connected\n\n')
|
||||
|
||||
bucket.add(res)
|
||||
|
||||
const ping = setInterval(() => {
|
||||
try {
|
||||
res.write(': ping\n\n')
|
||||
} catch {
|
||||
/* write after close — cleanup below handles it */
|
||||
}
|
||||
}, KEEPALIVE_MS)
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(ping)
|
||||
bucket.delete(res)
|
||||
}
|
||||
req.on('close', cleanup)
|
||||
res.on('error', cleanup)
|
||||
}
|
||||
|
||||
function writeTo(bucket, payload) {
|
||||
for (const res of bucket) {
|
||||
try {
|
||||
res.write(payload)
|
||||
} catch (err) {
|
||||
log.warn('sse write failed; dropping client', { message: err.message })
|
||||
bucket.delete(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan an ingested event out to the admin channel (always) and the public
|
||||
// channel (safe kinds only). A no-op when nobody is subscribed.
|
||||
function broadcast(event) {
|
||||
if (!event || !event.kind) return
|
||||
const frame = `data: ${JSON.stringify(event)}\n\n`
|
||||
if (clients.admin.size) writeTo(clients.admin, frame)
|
||||
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
|
||||
}
|
||||
|
||||
// Close every open stream (graceful shutdown).
|
||||
function closeAll() {
|
||||
for (const channel of Object.values(clients)) {
|
||||
for (const res of channel) {
|
||||
try {
|
||||
res.end()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
channel.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function stats() {
|
||||
return { publicClients: clients.public.size, adminClients: clients.admin.size }
|
||||
}
|
||||
|
||||
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user