Compare commits
89 Commits
853224b578
...
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 | |||
| 5da27879e5 | |||
| cda0c16149 | |||
| 82807d18d9 | |||
| d72deff2cc | |||
| 387db52510 | |||
| 5daf260db9 | |||
| f8bcc7f6a3 | |||
| cdd916e199 | |||
| 4ab46410be | |||
| 2b4c4c5235 | |||
| 3027bb0400 | |||
| b0c0d1fe9b | |||
| f2691959ff | |||
| 60d2121b83 | |||
| 20d3fbf594 | |||
| f8db61025b | |||
| 2067028070 | |||
| 03e62b56ad | |||
| 15cf8ea286 | |||
| 5f62eccdd8 | |||
| e8a54d9ff7 | |||
| 933206a1b8 | |||
| 1cfb79f5ae | |||
| 3ef84b41ef | |||
| 5df943095d | |||
| bb5cc68c54 | |||
| 17c1eb07e8 | |||
| 7a21cc636c | |||
| ad7aebb3ba | |||
| 0318d6fe9f | |||
| 433e02d3ef | |||
| a1f0675577 | |||
| d7fb274bad | |||
| 6af85c30b6 | |||
| 86e44a94a2 | |||
| 31b31c3a17 | |||
| 8fa34ca68e | |||
| 870971fc12 | |||
| 58852a5078 | |||
| cd678e75ce | |||
| a82f839c61 | |||
| 05933f8d94 | |||
| 073c010d72 | |||
| f305019c54 | |||
| 7e8ffeee6f | |||
| 6ab3e47d38 | |||
| ea46b5d346 | |||
| d38c98ad9e | |||
| ad9c556c9a | |||
| e84835a0fb | |||
| d89cc7e691 | |||
| 43db509293 | |||
| 6b04aa72c1 | |||
| 81318ae264 | |||
| 8ad18140d0 |
@@ -6,6 +6,18 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--prefix", "client"],
|
"runtimeArgs": ["run", "dev", "--prefix", "client"],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "server"],
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bot",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "bot"],
|
||||||
|
"port": 4100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
61
.env.example
61
.env.example
@@ -4,6 +4,10 @@
|
|||||||
# App
|
# App
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
# Separate, UNPUBLISHED port for server<->bot internal traffic (the decrypted
|
||||||
|
# bot-token route). Must match the port in the bot's SITE_INTERNAL_URL
|
||||||
|
# (docker-compose.yml) and must NEVER be published/proxied. See issue #33.
|
||||||
|
INTERNAL_PORT=3001
|
||||||
UPLOAD_DIR=/app/uploads
|
UPLOAD_DIR=/app/uploads
|
||||||
# Logging — written to BOTH the console and a log file.
|
# Logging — written to BOTH the console and a log file.
|
||||||
LOG_LEVEL=info # console verbosity: error | warn | info | debug
|
LOG_LEVEL=info # console verbosity: error | warn | info | debug
|
||||||
@@ -12,7 +16,8 @@ LOG_TO_FILE=true # set false for console-only
|
|||||||
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
||||||
LOG_FILE=app.log
|
LOG_FILE=app.log
|
||||||
|
|
||||||
# Database (the values here are shared by the `db` and `app` containers)
|
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
|
||||||
|
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=uomysticmoon
|
DB_NAME=uomysticmoon
|
||||||
@@ -28,18 +33,58 @@ JWT_EXPIRES_IN=1d
|
|||||||
COOKIE_SECURE=auto
|
COOKIE_SECURE=auto
|
||||||
COOKIE_NAME=uomm_token
|
COOKIE_NAME=uomm_token
|
||||||
|
|
||||||
|
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
||||||
|
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
||||||
|
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
|
||||||
|
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
|
||||||
|
# Integer hop count or "false" also accepted; a blanket "true" is rejected
|
||||||
|
# (coerced to 1) to prevent X-Forwarded-For spoofing.
|
||||||
|
TRUST_PROXY=1
|
||||||
|
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
|
||||||
|
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
||||||
|
DEBUG_TRUST_PROXY=0
|
||||||
|
|
||||||
|
# Optional TOTP two-factor (opt-in per user).
|
||||||
|
TOTP_ISSUER=UOMysticmoon
|
||||||
|
TOTP_CHALLENGE_TTL=5m
|
||||||
|
|
||||||
# First admin bootstrap — created only if no users exist yet.
|
# First admin bootstrap — created only if no users exist yet.
|
||||||
# Set, run once, then you can blank these out.
|
# Set, run once, then you can blank these out.
|
||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the
|
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
||||||
# client to fall back to a mailto: link instead.
|
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||||
SMTP_HOST=
|
# refresh token in the DB. Until it's connected, the contact form falls back to
|
||||||
SMTP_PORT=587
|
# a mailto: link (recipient = the `contact_email` site setting).
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
CONTACT_TO=UOMysticmoon@gmail.com
|
|
||||||
|
|
||||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Discord bot — internal API (server <-> bot/, see docker-compose.yml's `bot`
|
||||||
|
# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same
|
||||||
|
# variable in bot/.env.example — it is the only auth on both sides' /internal/*
|
||||||
|
# routes, so a mismatch silently breaks every server<->bot call with 401s.
|
||||||
|
# It also guards the server's /internal/bot-config route, which returns the
|
||||||
|
# DECRYPTED Discord token; with NODE_ENV=production the app REFUSES TO START if
|
||||||
|
# this is left blank, at this placeholder, or shorter than 16 chars. Generate a
|
||||||
|
# long random string. The Discord bot TOKEN itself is not an env var — it's
|
||||||
|
# entered in the admin panel (Discord Bot page) and stored encrypted in the DB.
|
||||||
|
#
|
||||||
|
# Defense in depth: even with a strong key, configure Pangolin/your reverse
|
||||||
|
# proxy to DENY /api/v1/internal (and never forward INTERNAL_PORT). The route no
|
||||||
|
# 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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,5 +31,8 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# local planning docs (not part of the tracked codebase)
|
||||||
|
.plans/
|
||||||
|
|
||||||
# scratch / temp scripts
|
# scratch / temp scripts
|
||||||
_*.ps1
|
_*.ps1
|
||||||
|
|||||||
@@ -234,10 +234,13 @@ who"; `activity_log` provides the history feed.
|
|||||||
|
|
||||||
## 7. Email
|
## 7. Email
|
||||||
|
|
||||||
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
|
`utils/mailer.js` (nodemailer) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
|
||||||
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
|
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
|
||||||
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
|
flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
|
||||||
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
|
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
|
COOKIE_NAME=uomm_token
|
||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
SMTP_HOST=
|
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
CONTACT_TO=UOMysticmoon@gmail.com
|
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
227
README.md
227
README.md
@@ -3,9 +3,10 @@
|
|||||||
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
|
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
|
||||||
shard — a full-stack app in one repo:
|
shard — a full-stack app in one repo:
|
||||||
|
|
||||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, JWT-in-cookie auth.
|
- **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).
|
- **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.
|
- **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).
|
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
- [First admin & site mode](#first-admin--site-mode)
|
- [First admin & site mode](#first-admin--site-mode)
|
||||||
- [Pages & routes](#pages--routes)
|
- [Pages & routes](#pages--routes)
|
||||||
- [API endpoints](#api-endpoints)
|
- [API endpoints](#api-endpoints)
|
||||||
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
|
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
@@ -35,10 +38,11 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
| Layer | Tech |
|
| Layer | Tech |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
||||||
| Auth | JWT in an httpOnly cookie, bcrypt password hashing |
|
| 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) |
|
| Database | MariaDB 11 (own container) |
|
||||||
| Frontend | React 18, Vite 5, React Router 6 |
|
| 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 |
|
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,18 +55,20 @@ UOMSITE/
|
|||||||
│ ├─ src/
|
│ ├─ src/
|
||||||
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||||
│ │ ├─ app.js middleware + static SPA + routes
|
│ │ ├─ app.js middleware + static SPA + routes
|
||||||
│ │ ├─ router/v1/ auth / public / admin route groups
|
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
|
||||||
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db)
|
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
|
||||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · validate
|
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
||||||
│ │ └─ utils/ auth (JWT/cookies) · db (pool) · mailer · logger
|
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
|
||||||
|
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
|
||||||
│ ├─ db/ schema.sql + seed.js
|
│ ├─ db/ schema.sql + seed.js
|
||||||
|
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
|
||||||
│ └─ .env.example
|
│ └─ .env.example
|
||||||
├─ client/ React + Vite SPA
|
├─ client/ React + Vite SPA
|
||||||
│ ├─ src/
|
│ ├─ src/
|
||||||
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
||||||
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
||||||
│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users) + editors
|
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
|
||||||
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, …
|
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
|
||||||
│ │ ├─ contexts/ AuthContext, SiteContext
|
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||||
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
||||||
│ │ └─ styles/theme.css design tokens
|
│ │ └─ styles/theme.css design tokens
|
||||||
@@ -187,7 +193,10 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| `/admin/wiki` | Wiki pages CRUD |
|
| `/admin/wiki` | Wiki pages CRUD |
|
||||||
| `/admin/settings` | Site settings |
|
| `/admin/settings` | Site settings |
|
||||||
| `/admin/activity` | Activity log |
|
| `/admin/activity` | Activity log |
|
||||||
|
| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
|
||||||
|
| `/admin/auth-providers` | Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
|
||||||
| `/admin/users` | User management |
|
| `/admin/users` | User management |
|
||||||
|
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,12 +204,123 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
|
|
||||||
| Group | Base | Auth |
|
| Group | Base | Auth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Auth | `/api/v1/auth` (`login`, `logout`, `me`) | cookie |
|
| Auth (web) | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie |
|
||||||
|
| Auth (mobile) | `/api/v1/auth/mobile` (`login`, `refresh`, `logout`) | bearer (access + refresh tokens) |
|
||||||
|
| 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 |
|
| 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`, `users`) | cookie (admin) |
|
| 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`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
|
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||||
|
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
||||||
|
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API documentation (Swagger)
|
||||||
|
|
||||||
|
The full API is documented as an **OpenAPI 3.0** spec and served with **Swagger UI**:
|
||||||
|
|
||||||
|
| URL | What |
|
||||||
|
|---|---|
|
||||||
|
| `http://localhost:3000/api/docs` | Interactive Swagger UI (try-it-out, auth) |
|
||||||
|
| `http://localhost:3000/api/docs.json` | Raw OpenAPI 3.0 spec (JSON) |
|
||||||
|
|
||||||
|
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
|
||||||
|
groups) with its summary, parameters, request body, security requirement, and the response codes it
|
||||||
|
actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `429` rate limits, …).
|
||||||
|
|
||||||
|
**Authentication in the UI** — click **Authorize** and provide either:
|
||||||
|
|
||||||
|
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
|
||||||
|
`POST /api/v1/auth/login`), or
|
||||||
|
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
||||||
|
`Authorization: Bearer <token>`).
|
||||||
|
|
||||||
|
**Regenerating the spec** — the spec is generated from `#swagger.*` annotations next to each route
|
||||||
|
(`server/src/router/**`) plus the shared definitions in `server/swagger/swagger.js`
|
||||||
|
([swagger-autogen](https://github.com/davibaltar/swagger-autogen)). The output
|
||||||
|
`server/swagger/swagger-output.json` is committed so the docs work with no build step. After adding
|
||||||
|
or changing a route, regenerate it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm run swagger # → server/swagger/swagger-output.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,29 +332,94 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `NODE_ENV` | `production` | |
|
| `NODE_ENV` | `production` | |
|
||||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||||
|
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||||
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
||||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
||||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||||
| `JWT_SECRET` | — | **required** — long random string |
|
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
||||||
| `JWT_EXPIRES_IN` | `1d` | token + cookie lifetime |
|
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
||||||
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
||||||
| `COOKIE_NAME` | `uomm_token` | |
|
| `COOKIE_NAME` | `uomm_token` | |
|
||||||
|
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
|
||||||
|
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
|
||||||
|
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
|
||||||
|
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
|
||||||
|
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
|
||||||
|
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
|
||||||
|
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
|
||||||
|
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
|
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||||
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
|
|
||||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
| `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) |
|
| `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`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected) · bcrypt hashing · login &
|
**Session & authorization**
|
||||||
contact rate limiting · `express-validator` on writes · `helmet` · admin routes `noindex` +
|
|
||||||
`robots.txt` disallow · `trust proxy` for correct client IPs behind Pangolin · first admin seeded
|
- All auth flows go through one **session service** (`server/src/auth/`): controllers call
|
||||||
from env (no hardcoded credentials) · `.env` git-ignored. Passwords and request bodies are never
|
`sessionService.createSession(user, authMethod)` and middleware calls `validateSession()`, so web
|
||||||
logged. SMTP is optional — the contact form falls back to a `mailto:` link when unconfigured.
|
cookies, mobile bearer tokens, and SSO all produce the *same* authenticated session model.
|
||||||
|
`utils/auth.js` remains a thin backward-compat facade.
|
||||||
|
- JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected), bcrypt password hashing.
|
||||||
|
- Admin routes are **re-validated against the database on every request**, so a demoted or deleted
|
||||||
|
user loses access immediately instead of keeping their old role until the token expires.
|
||||||
|
- **Role-based authorization** — admin-only endpoints (users, site mode, settings, auth providers)
|
||||||
|
are gated by a `requireRole` check, so a lower-privilege editor can't reach them.
|
||||||
|
|
||||||
|
**Mobile bearer auth**
|
||||||
|
|
||||||
|
- Native clients use `/api/v1/auth/mobile/*`: a short-lived **access token** (bearer JWT, validated
|
||||||
|
by the same middleware as the cookie) plus a long-lived, **server-stored, revocable refresh
|
||||||
|
token** that is **rotated on every refresh** (a replayed refresh token is single-use). Refresh
|
||||||
|
tokens are stored **hashed** (never in the clear); logout revokes one or all. Mobile login reuses
|
||||||
|
the same bot-scoring + backoff defenses as web, with single-request TOTP.
|
||||||
|
|
||||||
|
**Single sign-on (OAuth2 / OIDC)**
|
||||||
|
|
||||||
|
- Pluggable providers — built-in **Google** and **Discord** (endpoints fixed in code; admins supply
|
||||||
|
only client id/secret) plus fully-configurable **custom OIDC/OAuth2** providers, managed from the
|
||||||
|
**Authentication** admin panel. Only `enabled` + fully-configured providers are shown to users.
|
||||||
|
- **Link-only** by policy: an SSO login succeeds *only* if the external identity is already linked to
|
||||||
|
an existing account (linked by the user from **Account**). External identities are **never
|
||||||
|
auto-provisioned** — no one gains access without an account you created.
|
||||||
|
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
|
||||||
|
**PKCE**; OAuth client secrets are **encrypted at rest** (AES-256-GCM) and never returned to any
|
||||||
|
client. SSO logins go through the same `sessionService`, so login/activity logging, RBAC, and bot
|
||||||
|
protection are identical to a local login.
|
||||||
|
|
||||||
|
**Login hardening**
|
||||||
|
|
||||||
|
- **Optional per-user TOTP two-factor** (opt-in, self-service on `/admin/account`). When enabled,
|
||||||
|
the password step issues only a short-lived, non-session `stage:'totp'` challenge; a session
|
||||||
|
cookie is granted only after the second factor verifies.
|
||||||
|
- **Login throttling** — `express-slow-down` + a hard rate cap + a separate per-IP exponential
|
||||||
|
backoff, with generic error messages that don't reveal whether the username exists.
|
||||||
|
- **Honeypot** field on the login form; submissions that fill it are treated as bots.
|
||||||
|
- **Bot-scoring + automatic IP ban** — weighted scoring of CMS-scanner paths and junk 404s (with a
|
||||||
|
periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the
|
||||||
|
score. Admins get visibility into this on the **Bot Activity** panel: currently banned IPs and a
|
||||||
|
recent-events feed (in-memory, most-recent-first), plus a logged emergency **unban** for false
|
||||||
|
positives — read + unban only, not a scoring-config surface.
|
||||||
|
|
||||||
|
**Uploads & input**
|
||||||
|
|
||||||
|
- Uploaded file extensions are derived from the **validated mimetype**, not the client-supplied
|
||||||
|
filename (prevents a disguised-extension upload).
|
||||||
|
- `express-validator` on all writes; usernames are validated **and** uniqueness-checked on update.
|
||||||
|
|
||||||
|
**Platform**
|
||||||
|
|
||||||
|
- `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. 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
45
bot/.env.example
Normal file
45
bot/.env.example
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ─── UOMysticmoon Discord bot — local dev environment ───
|
||||||
|
# Copy to bot/.env for running `npm run dev` outside Docker.
|
||||||
|
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||||
|
#
|
||||||
|
# NOTE: there is no Discord bot token here on purpose. The token is entered
|
||||||
|
# in the admin panel (Discord Bot page), stored encrypted in the main site's
|
||||||
|
# DB, and pushed to this process in-memory over the internal API. It is
|
||||||
|
# never read from an env var and never written to this process's disk.
|
||||||
|
|
||||||
|
PORT=4100
|
||||||
|
|
||||||
|
# Logging — written to BOTH the console and a log file (default <bot>/logs/bot.log).
|
||||||
|
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
|
||||||
|
FILE_LOG_LEVEL=debug # file verbosity
|
||||||
|
LOG_TO_FILE=true # set false for console-only
|
||||||
|
# LOG_DIR= # defaults to bot/logs
|
||||||
|
# LOG_FILE=bot.log
|
||||||
|
|
||||||
|
# Shared secret for the internal API between this bot and the main site
|
||||||
|
# (server/). MUST be byte-for-byte identical to BOT_INTERNAL_KEY in
|
||||||
|
# server/.env.example / the root .env.example — it is the only auth on both
|
||||||
|
# sides' /internal/* routes, so a mismatch silently breaks every server<->bot
|
||||||
|
# call with 401s. Generate one long random string and copy it to both places.
|
||||||
|
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||||
|
|
||||||
|
# Where this bot calls back to the main site to fetch its config on boot
|
||||||
|
# (GET .../internal/bot-config), so a restart self-reconnects without needing
|
||||||
|
# the admin panel to push config again. This targets the site's UNPUBLISHED
|
||||||
|
# internal port (INTERNAL_PORT, default 3001) — NOT the public 3000. See #33.
|
||||||
|
SITE_INTERNAL_URL=http://localhost:3001/internal/bot-config
|
||||||
|
|
||||||
|
# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any
|
||||||
|
# visitor's browser can fetch. Used by /wiki (search) and /announce
|
||||||
|
# (re-post an existing news item).
|
||||||
|
SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
|
||||||
|
|
||||||
|
# Database (Phase 2+) — same physical DB as the main site, but the bot only
|
||||||
|
# ever reads/writes its OWN tables (guild_config, mod_actions, warnings, and
|
||||||
|
# more in later phases). It never touches site tables (users, bot_config,
|
||||||
|
# etc.) directly. Point this at the same DB the server/ uses.
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=uomysticmoon
|
||||||
|
DB_USER=uomm
|
||||||
|
DB_PASSWORD=change-me-db-password
|
||||||
3
bot/.gitignore
vendored
Normal file
3
bot/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
logs/
|
||||||
16
bot/Dockerfile
Normal file
16
bot/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app/bot
|
||||||
|
|
||||||
|
COPY bot/package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY bot/ .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/bot/logs && chown -R node:node /app/bot/logs
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 4100
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
1609
bot/package-lock.json
generated
Normal file
1609
bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
bot/package.json
Normal file
24
bot/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "uomysticmoon-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Discord bot for the UOMysticmoon community server",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js"
|
||||||
|
},
|
||||||
|
"keywords": ["discord", "discord.js"],
|
||||||
|
"author": "whitlocktech",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"discord.js": "^14.16.3",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"mariadb": "^3.3.1",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
bot/src/app.js
Normal file
12
bot/src/app.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const internalRouter = require('./internal/internal.routes')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ status: 'ok' }))
|
||||||
|
app.use('/internal', internalRouter)
|
||||||
|
|
||||||
|
module.exports = app
|
||||||
38
bot/src/bootstrap.js
vendored
Normal file
38
bot/src/bootstrap.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Runs once at process start, before the internal Express server is
|
||||||
|
// considered ready. Fetches current config from the main site (token,
|
||||||
|
// guildId, enabled) and reconnects immediately if enabled — so a bot
|
||||||
|
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
||||||
|
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
||||||
|
// extra HTTP client dependency needed for a single startup call.
|
||||||
|
const discordManager = require('./discord/discordManager')
|
||||||
|
const createLogger = require('./utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('bootstrap')
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const siteUrl = process.env.SITE_INTERNAL_URL
|
||||||
|
const key = process.env.BOT_INTERNAL_KEY
|
||||||
|
if (!siteUrl || !key) {
|
||||||
|
log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||||
|
if (!res.ok) {
|
||||||
|
log.error('boot-time config fetch failed', { status: res.status })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const config = await res.json()
|
||||||
|
if (config.enabled) {
|
||||||
|
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||||
|
await discordManager.start({ token: config.token, guildId: config.guildId })
|
||||||
|
} else {
|
||||||
|
log.info('boot-time config says disabled — staying disconnected')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('boot-time config fetch errored', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = bootstrap
|
||||||
41
bot/src/db.js
Normal file
41
bot/src/db.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) —
|
||||||
|
// mirrors server/src/utils/db.js. The bot never reads/writes any table it
|
||||||
|
// doesn't own; site-owned tables (users, bot_config, etc.) are reached only
|
||||||
|
// through the internal API, never directly. Schema for these tables lives in
|
||||||
|
// server/db/schema.sql (same physical database, ensured by the main server on
|
||||||
|
// boot) — there's no separate migration tool to justify a second database for
|
||||||
|
// a single-guild v1 bot.
|
||||||
|
const mariadb = require('mariadb')
|
||||||
|
|
||||||
|
const pool = mariadb.createPool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: Number(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'uomysticmoon',
|
||||||
|
connectionLimit: 5,
|
||||||
|
insertIdAsNumber: true,
|
||||||
|
bigIntAsNumber: true,
|
||||||
|
decimalAsNumber: true,
|
||||||
|
// The driver defaults to 'local' — silently serializing bound JS Date
|
||||||
|
// params using the HOST MACHINE's local offset instead of the DB session's
|
||||||
|
// timezone (discovered via temp_roles.expires_at coming back hours off in
|
||||||
|
// dev, CDT vs the container's UTC). 'auto' negotiates the actual session
|
||||||
|
// timezone so Date round-trips correctly regardless of host TZ.
|
||||||
|
timezone: 'auto',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function query(sql, params) {
|
||||||
|
const conn = await pool.getConnection()
|
||||||
|
try {
|
||||||
|
return await conn.query(sql, params)
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { query, close }
|
||||||
49
bot/src/discord/commands/announce.command.js
Normal file
49
bot/src/discord/commands/announce.command.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const siteApiClient = require('../../site/siteApiClient')
|
||||||
|
const newsAnnounce = require('../newsAnnounce')
|
||||||
|
|
||||||
|
function siteOrigin() {
|
||||||
|
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||||
|
return new URL(base).origin
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'announce',
|
||||||
|
description: 'Re-post or boost an existing news item.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'post', description: 'News post id or slug', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const idOrSlug = interaction.options.getString('post', true)
|
||||||
|
await interaction.deferReply({ ephemeral: true })
|
||||||
|
|
||||||
|
const result = await siteApiClient.getNewsPost(idOrSlug)
|
||||||
|
if (result.maintenance) {
|
||||||
|
await interaction.editReply({ content: `Can't reach the site right now: ${result.message || 'maintenance mode'}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
await interaction.editReply({ content: `Couldn't find that news post ("${idOrSlug}").` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = result.data
|
||||||
|
const origin = siteOrigin()
|
||||||
|
try {
|
||||||
|
await newsAnnounce.postAnnounce(interaction.client, interaction.guildId, {
|
||||||
|
title: post.title,
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
url: `${origin}/site/news`,
|
||||||
|
// image_url is stored relative — Discord embeds require an absolute URL.
|
||||||
|
imageUrl: post.image_url ? new URL(post.image_url, origin).toString() : null,
|
||||||
|
})
|
||||||
|
await interaction.editReply({ content: `Posted "${post.title}" to the news channel.` })
|
||||||
|
} catch (err) {
|
||||||
|
await interaction.editReply({ content: `Couldn't post: ${err.message}` })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
30
bot/src/discord/commands/autorole.command.js
Normal file
30
bot/src/discord/commands/autorole.command.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'autorole',
|
||||||
|
description: 'View or set the role automatically assigned to new members on join.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'role',
|
||||||
|
description: 'Role to auto-assign on join. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Role,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const role = interaction.options.getRole('role')
|
||||||
|
if (!role) {
|
||||||
|
const currentId = await guildConfig.getAutoRoleId(interaction.guildId)
|
||||||
|
const content = currentId ? `Auto-role is set to <@&${currentId}>.` : 'No auto-role is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setAutoRoleId(interaction.guildId, role.id)
|
||||||
|
await interaction.reply({ content: `Auto-role set to ${role}. New members will get this automatically.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
34
bot/src/discord/commands/ban.command.js
Normal file
34
bot/src/discord/commands/ban.command.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'ban',
|
||||||
|
description: 'Ban a member from the server.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.BanMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to ban', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the ban', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't ban yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
if (member && !member.bannable) {
|
||||||
|
await interaction.reply({ content: "I don't have permission to ban that member (role hierarchy).", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.guild.members.ban(user, { reason })
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'ban', target: user, staffUser: interaction.user, reason })
|
||||||
|
await interaction.reply({ content: `Banned ${user.tag}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
73
bot/src/discord/commands/filter.command.js
Normal file
73
bot/src/discord/commands/filter.command.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const filterWords = require('../../model/filterWords')
|
||||||
|
const filterCache = require('../../filter/filterCache')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'filter',
|
||||||
|
description: 'Manage the banned-word filter.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add a word to the filter.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'word', description: 'Word or phrase to ban', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{
|
||||||
|
name: 'severity',
|
||||||
|
description: 'Auto-action when triggered (default: delete)',
|
||||||
|
type: ApplicationCommandOptionType.String,
|
||||||
|
required: false,
|
||||||
|
choices: [
|
||||||
|
{ name: 'Delete only', value: 'delete' },
|
||||||
|
{ name: 'Delete + warn', value: 'warn' },
|
||||||
|
{ name: 'Delete + mute (10m)', value: 'mute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'remove',
|
||||||
|
description: 'Remove a word from the filter.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'word', description: 'Word or phrase to remove', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'List all filtered words.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'add') {
|
||||||
|
const word = interaction.options.getString('word', true)
|
||||||
|
const severity = interaction.options.getString('severity') || 'delete'
|
||||||
|
await filterWords.add({ guildId: interaction.guildId, word, severity, addedBy: interaction.user.id, addedByTag: interaction.user.tag })
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: `Added "${word}" to the filter (${severity}).`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const word = interaction.options.getString('word', true)
|
||||||
|
const removed = await filterWords.remove(interaction.guildId, word)
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: removed ? `Removed "${word}" from the filter.` : `"${word}" wasn't in the filter.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const words = await filterWords.list(interaction.guildId)
|
||||||
|
const content = words.length === 0 ? 'The filter list is empty.' : words.map((w) => `${w.word} (${w.severity})`).join('\n')
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
61
bot/src/discord/commands/filterallow.command.js
Normal file
61
bot/src/discord/commands/filterallow.command.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const filterAllowlist = require('../../model/filterAllowlist')
|
||||||
|
const filterCache = require('../../filter/filterCache')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'filterallow',
|
||||||
|
description: 'Manage roles/channels that bypass the filter entirely.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'role',
|
||||||
|
description: 'Toggle a role in/out of the filter bypass list.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [{ name: 'role', description: 'Role to toggle', type: ApplicationCommandOptionType.Role, required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Toggle a channel in/out of the filter bypass list.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [{ name: 'channel', description: 'Channel to toggle', type: ApplicationCommandOptionType.Channel, required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'Show current filter bypass roles/channels.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'role') {
|
||||||
|
const role = interaction.options.getRole('role', true)
|
||||||
|
const nowAllowed = await filterAllowlist.toggleRole(interaction.guildId, role.id)
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: `${role} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'channel') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const nowAllowed = await filterAllowlist.toggleChannel(interaction.guildId, channel.id)
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: `${channel} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const [roles, channels] = await Promise.all([
|
||||||
|
filterAllowlist.getRoles(interaction.guildId),
|
||||||
|
filterAllowlist.getChannels(interaction.guildId),
|
||||||
|
])
|
||||||
|
const roleText = roles.length ? roles.map((id) => `<@&${id}>`).join(', ') : 'none'
|
||||||
|
const channelText = channels.length ? channels.map((id) => `<#${id}>`).join(', ') : 'none'
|
||||||
|
await interaction.reply({ content: `Bypass roles: ${roleText}\nBypass channels: ${channelText}`, ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
31
bot/src/discord/commands/index.js
Normal file
31
bot/src/discord/commands/index.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Command registry. Each module exports { data, execute } — `data` is the
|
||||||
|
// slash-command definition pushed to Discord (registerCommands), `execute` is
|
||||||
|
// the interactionCreate handler (dispatch). Adding a new command is just
|
||||||
|
// adding a file here — discordManager.js never needs to change.
|
||||||
|
const commands = [
|
||||||
|
require('./ping.command'),
|
||||||
|
require('./modlog.command'),
|
||||||
|
require('./ban.command'),
|
||||||
|
require('./kick.command'),
|
||||||
|
require('./mute.command'),
|
||||||
|
require('./warn.command'),
|
||||||
|
require('./warnings.command'),
|
||||||
|
require('./filter.command'),
|
||||||
|
require('./filterallow.command'),
|
||||||
|
require('./schedule.command'),
|
||||||
|
require('./rolemenu.command'),
|
||||||
|
require('./autorole.command'),
|
||||||
|
require('./role.command'),
|
||||||
|
require('./roles.command'),
|
||||||
|
require('./invite.command'),
|
||||||
|
require('./news.command'),
|
||||||
|
require('./announce.command'),
|
||||||
|
require('./wiki.command'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const byName = new Map(commands.map((c) => [c.data.name, c]))
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
all: commands,
|
||||||
|
get: (name) => byName.get(name),
|
||||||
|
}
|
||||||
85
bot/src/discord/commands/invite.command.js
Normal file
85
bot/src/discord/commands/invite.command.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
const inviteLog = require('../../model/inviteLog')
|
||||||
|
const inviteRotator = require('../../invites/inviteRotator')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'invite',
|
||||||
|
description: 'Manage the auto-rotating primary server invite.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'View or set the channel new invites are created in.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Channel to create invites in. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Channel,
|
||||||
|
channel_types: [ChannelType.GuildText],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rotate',
|
||||||
|
description: 'Revoke the current invite and generate a new one now.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'log',
|
||||||
|
description: 'Show recent invite rotation history.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'channel') {
|
||||||
|
const channel = interaction.options.getChannel('channel')
|
||||||
|
if (!channel) {
|
||||||
|
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
|
||||||
|
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
|
||||||
|
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'rotate') {
|
||||||
|
await interaction.deferReply({ ephemeral: true })
|
||||||
|
try {
|
||||||
|
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
|
||||||
|
triggeredBy: interaction.user.id,
|
||||||
|
triggeredByTag: interaction.user.tag,
|
||||||
|
})
|
||||||
|
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
|
||||||
|
} catch (err) {
|
||||||
|
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'log') {
|
||||||
|
const rows = await inviteLog.list(interaction.guildId, 10)
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = rows.map((r) => {
|
||||||
|
const who = r.triggered_by_tag || 'automatic (scheduled)'
|
||||||
|
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
|
||||||
|
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
|
||||||
|
})
|
||||||
|
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
38
bot/src/discord/commands/kick.command.js
Normal file
38
bot/src/discord/commands/kick.command.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'kick',
|
||||||
|
description: 'Kick a member from the server.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.KickMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to kick', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the kick', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't kick yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!member.kickable) {
|
||||||
|
await interaction.reply({ content: "I don't have permission to kick that member (role hierarchy).", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await member.kick(reason)
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'kick', target: user, staffUser: interaction.user, reason })
|
||||||
|
await interaction.reply({ content: `Kicked ${user.tag}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
33
bot/src/discord/commands/modlog.command.js
Normal file
33
bot/src/discord/commands/modlog.command.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'modlog',
|
||||||
|
description: 'View or set the mod-log channel (ban/kick/mute/warn actions post here).',
|
||||||
|
// Configuration, not a moderation action — gated to Manage Server rather
|
||||||
|
// than the ModerateMembers bit the action commands use.
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Channel to post mod-log entries to. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Channel,
|
||||||
|
channel_types: [ChannelType.GuildText],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const channel = interaction.options.getChannel('channel')
|
||||||
|
if (!channel) {
|
||||||
|
const currentId = await guildConfig.getModLogChannelId(interaction.guildId)
|
||||||
|
const content = currentId ? `Mod-log channel is set to <#${currentId}>.` : 'No mod-log channel is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setModLogChannelId(interaction.guildId, channel.id)
|
||||||
|
await interaction.reply({ content: `Mod-log channel set to ${channel}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
49
bot/src/discord/commands/mute.command.js
Normal file
49
bot/src/discord/commands/mute.command.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
const { parseDuration, MAX_TIMEOUT_MS } = require('../../utils/duration')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'mute',
|
||||||
|
description: 'Timeout a member for a duration (e.g. 10m, 2h, 1d).',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to mute', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'duration', description: 'e.g. 30s, 10m, 2h, 1d (max 28d)', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the mute', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const durationInput = interaction.options.getString('duration', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't mute yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = parseDuration(durationInput)
|
||||||
|
if (!ms) {
|
||||||
|
await interaction.reply({ content: 'Invalid duration — use a number plus s/m/h/d, e.g. `10m`, `2h`, `1d`.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clampedMs = Math.min(ms, MAX_TIMEOUT_MS)
|
||||||
|
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!member.moderatable) {
|
||||||
|
await interaction.reply({ content: "I don't have permission to timeout that member (role hierarchy).", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await member.timeout(clampedMs, reason)
|
||||||
|
const durationSeconds = Math.round(clampedMs / 1000)
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'mute', target: user, staffUser: interaction.user, reason, durationSeconds })
|
||||||
|
await interaction.reply({ content: `Muted ${user.tag} for ${durationInput}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
31
bot/src/discord/commands/news.command.js
Normal file
31
bot/src/discord/commands/news.command.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'news',
|
||||||
|
description: 'View or set the channel news posts are announced to.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Channel for news announcements. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Channel,
|
||||||
|
channel_types: [ChannelType.GuildText],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const channel = interaction.options.getChannel('channel')
|
||||||
|
if (!channel) {
|
||||||
|
const currentId = await guildConfig.getNewsChannelId(interaction.guildId)
|
||||||
|
const content = currentId ? `News channel is set to <#${currentId}>.` : 'No news channel is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setNewsChannelId(interaction.guildId, channel.id)
|
||||||
|
await interaction.reply({ content: `News channel set to ${channel}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
15
bot/src/discord/commands/ping.command.js
Normal file
15
bot/src/discord/commands/ping.command.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const { PermissionFlagsBits } = require('discord.js')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'ping',
|
||||||
|
description: 'Health-check — replies pong if the bot is alive and staff-permitted.',
|
||||||
|
// Restricted by default to members with Moderate Members — proves slash
|
||||||
|
// commands can be permission-gated via Discord's own permission model,
|
||||||
|
// per the spec's "restrict staff commands via Discord's permission system".
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
await interaction.reply({ content: 'pong', ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
71
bot/src/discord/commands/role.command.js
Normal file
71
bot/src/discord/commands/role.command.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const tempRoles = require('../../model/tempRoles')
|
||||||
|
const { parseDuration } = require('../../utils/duration')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'role',
|
||||||
|
description: 'Assign or remove a role for a single member.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add a role to a member, optionally temporary.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'role', description: 'Role to add', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'duration', description: 'Optional — makes this temporary, e.g. 1h, 2d, 7d', type: ApplicationCommandOptionType.String, required: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'remove',
|
||||||
|
description: 'Remove a role from a member.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'role', description: 'Role to remove', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const role = interaction.options.getRole('role', true)
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'add') {
|
||||||
|
await member.roles.add(role.id)
|
||||||
|
const durationInput = interaction.options.getString('duration')
|
||||||
|
if (!durationInput) {
|
||||||
|
await interaction.reply({ content: `Added ${role} to ${user.tag}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ms = parseDuration(durationInput)
|
||||||
|
if (!ms) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Added ${role}, but "${durationInput}" isn't a valid duration so it won't expire automatically. Use e.g. 1h, 2d, 7d.`,
|
||||||
|
ephemeral: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const expiresAt = new Date(Date.now() + ms)
|
||||||
|
await tempRoles.add({ guildId: interaction.guildId, userId: user.id, roleId: role.id, expiresAt, createdBy: interaction.user.id })
|
||||||
|
await interaction.reply({ content: `Added ${role} to ${user.tag} until ${expiresAt.toLocaleString()}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
await member.roles.remove(role.id)
|
||||||
|
await tempRoles.remove(interaction.guildId, user.id, role.id)
|
||||||
|
await interaction.reply({ content: `Removed ${role} from ${user.tag}.`, ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
85
bot/src/discord/commands/rolemenu.command.js
Normal file
85
bot/src/discord/commands/rolemenu.command.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
const {
|
||||||
|
PermissionFlagsBits,
|
||||||
|
ApplicationCommandOptionType,
|
||||||
|
ChannelType,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
} = require('discord.js')
|
||||||
|
|
||||||
|
const roleMenus = require('../../model/roleMenus')
|
||||||
|
|
||||||
|
// Capped at 5 roles per menu — a single Discord action row holds at most 5
|
||||||
|
// buttons, and one row keeps this a single simple slash command instead of
|
||||||
|
// needing a multi-step builder/modal flow.
|
||||||
|
const MAX_ROLES = 5
|
||||||
|
|
||||||
|
// role1/label1 are declared inline in `data` (ahead of the optional
|
||||||
|
// `description` option, per Discord's required-before-optional rule) — this
|
||||||
|
// generates the rest, all optional.
|
||||||
|
function roleOptions(from, to) {
|
||||||
|
const opts = []
|
||||||
|
for (let i = from; i <= to; i++) {
|
||||||
|
opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false })
|
||||||
|
opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false })
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'rolemenu',
|
||||||
|
description: 'Post a button menu for self-assignable roles (up to 5).',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
// Discord requires all required options before any optional ones across
|
||||||
|
// the whole array — role1 (required) must come before description
|
||||||
|
// (optional), even though they read more naturally in the other order.
|
||||||
|
options: [
|
||||||
|
{ name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||||
|
{ name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false },
|
||||||
|
{ name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false },
|
||||||
|
...roleOptions(2, MAX_ROLES),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const title = interaction.options.getString('title', true)
|
||||||
|
const description = interaction.options.getString('description') || undefined
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (let i = 1; i <= MAX_ROLES; i++) {
|
||||||
|
const role = interaction.options.getRole(`role${i}`)
|
||||||
|
if (!role) continue
|
||||||
|
const label = interaction.options.getString(`label${i}`) || role.name
|
||||||
|
entries.push({ roleId: role.id, label })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
|
||||||
|
if (description) embed.setDescription(description)
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder().addComponents(
|
||||||
|
entries.map((e) =>
|
||||||
|
new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const message = await channel.send({ embeds: [embed], components: [row] })
|
||||||
|
await roleMenus.add({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
messageId: message.id,
|
||||||
|
mapping: entries,
|
||||||
|
createdBy: interaction.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
68
bot/src/discord/commands/roles.command.js
Normal file
68
bot/src/discord/commands/roles.command.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
// Bulk targeting is "by existing role" only — the spec also mentions an
|
||||||
|
// explicit list of members, but Discord slash commands have no multi-user
|
||||||
|
// picker, so that variant is deferred rather than faked with a handful of
|
||||||
|
// user1..user5 options that would feel arbitrary and cramped.
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'roles',
|
||||||
|
description: 'Bulk role operations across members who share an existing role.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'bulk-assign',
|
||||||
|
description: 'Add a role to every member who has another role.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'add-role', description: 'Role to add to those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bulk-remove',
|
||||||
|
description: 'Remove a role from every member who has another role.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'remove-role', description: 'Role to remove from those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
// Fetching every member + looping role updates can easily exceed
|
||||||
|
// Discord's 3-second initial-response window.
|
||||||
|
await interaction.deferReply({ ephemeral: true })
|
||||||
|
|
||||||
|
const hasRole = interaction.options.getRole('has-role', true)
|
||||||
|
const members = await interaction.guild.members.fetch()
|
||||||
|
const targets = members.filter((m) => m.roles.cache.has(hasRole.id))
|
||||||
|
|
||||||
|
if (sub === 'bulk-assign') {
|
||||||
|
const addRole = interaction.options.getRole('add-role', true)
|
||||||
|
let count = 0
|
||||||
|
for (const member of targets.values()) {
|
||||||
|
if (!member.roles.cache.has(addRole.id)) {
|
||||||
|
await member.roles.add(addRole.id).catch(() => {})
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await interaction.editReply({ content: `Added ${addRole} to ${count} member(s) who have ${hasRole}.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'bulk-remove') {
|
||||||
|
const removeRole = interaction.options.getRole('remove-role', true)
|
||||||
|
let count = 0
|
||||||
|
for (const member of targets.values()) {
|
||||||
|
if (member.roles.cache.has(removeRole.id)) {
|
||||||
|
await member.roles.remove(removeRole.id).catch(() => {})
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await interaction.editReply({ content: `Removed ${removeRole} from ${count} member(s) who have ${hasRole}.` })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
119
bot/src/discord/commands/schedule.command.js
Normal file
119
bot/src/discord/commands/schedule.command.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const scheduledMessages = require('../../model/scheduledMessages')
|
||||||
|
const scheduler = require('../../scheduler/scheduler')
|
||||||
|
const { parseDuration } = require('../../utils/duration')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'schedule',
|
||||||
|
description: 'Manage recurring and one-off scheduled channel messages.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'recurring',
|
||||||
|
description: 'Schedule a recurring message on a cron schedule.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||||
|
{ name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'once',
|
||||||
|
description: 'Schedule a one-off message for a future time.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||||
|
{ name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'remove',
|
||||||
|
description: 'Remove a scheduled message by id.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'List all scheduled messages.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'recurring') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const cronExpr = interaction.options.getString('cron', true)
|
||||||
|
const message = interaction.options.getString('message', true)
|
||||||
|
if (!cron.validate(cronExpr)) {
|
||||||
|
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = await scheduledMessages.addRecurring({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
content: message,
|
||||||
|
cronExpression: cronExpr,
|
||||||
|
createdBy: interaction.user.id,
|
||||||
|
createdByTag: interaction.user.tag,
|
||||||
|
})
|
||||||
|
await scheduler.refresh()
|
||||||
|
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'once') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const inInput = interaction.options.getString('in', true)
|
||||||
|
const message = interaction.options.getString('message', true)
|
||||||
|
const ms = parseDuration(inInput)
|
||||||
|
if (!ms) {
|
||||||
|
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const runAt = new Date(Date.now() + ms)
|
||||||
|
const id = await scheduledMessages.addOnce({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
content: message,
|
||||||
|
runAt,
|
||||||
|
createdBy: interaction.user.id,
|
||||||
|
createdByTag: interaction.user.tag,
|
||||||
|
})
|
||||||
|
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const id = interaction.options.getInteger('id', true)
|
||||||
|
const removed = await scheduledMessages.remove(interaction.guildId, id)
|
||||||
|
await scheduler.refresh()
|
||||||
|
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const rows = await scheduledMessages.list(interaction.guildId)
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = rows.map((r) => {
|
||||||
|
const kind = r.cron_expression
|
||||||
|
? `cron \`${r.cron_expression}\``
|
||||||
|
: r.sent_at
|
||||||
|
? `sent ${new Date(r.sent_at).toLocaleString()}`
|
||||||
|
: `due ${new Date(r.run_at).toLocaleString()}`
|
||||||
|
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
|
||||||
|
})
|
||||||
|
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
40
bot/src/discord/commands/warn.command.js
Normal file
40
bot/src/discord/commands/warn.command.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
const warnings = require('../../model/warnings')
|
||||||
|
|
||||||
|
// Escalation (e.g. "3 active warns -> auto-mute for X hours") and warning
|
||||||
|
// decay/expiry are in the original spec but deferred past this phase — this
|
||||||
|
// just records the warning and posts it to the mod-log, matching the
|
||||||
|
// "Suggested Build Order" step 2 scope (core moderation).
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'warn',
|
||||||
|
description: 'Log a warning against a member.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to warn', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the warning', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't warn yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await warnings.add({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
targetUserId: user.id,
|
||||||
|
targetTag: user.tag,
|
||||||
|
staffUserId: interaction.user.id,
|
||||||
|
staffTag: interaction.user.tag,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'warn', target: user, staffUser: interaction.user, reason })
|
||||||
|
await interaction.reply({ content: `Warned ${user.tag}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
34
bot/src/discord/commands/warnings.command.js
Normal file
34
bot/src/discord/commands/warnings.command.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, EmbedBuilder } = require('discord.js')
|
||||||
|
|
||||||
|
const warnings = require('../../model/warnings')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'warnings',
|
||||||
|
description: "List a member's active warnings.",
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to look up', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const rows = await warnings.listActive(interaction.guildId, user.id)
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await interaction.reply({ content: `${user.tag} has no active warnings.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0xe0b070)
|
||||||
|
.setTitle(`Warnings — ${user.tag}`)
|
||||||
|
.setDescription(
|
||||||
|
rows
|
||||||
|
.map((w, i) => `**${i + 1}.** ${w.reason || '(no reason given)'} — by ${w.staff_tag || 'unknown'} on ${new Date(w.created_at).toLocaleDateString()}`)
|
||||||
|
.join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
40
bot/src/discord/commands/wiki.command.js
Normal file
40
bot/src/discord/commands/wiki.command.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const siteApiClient = require('../../site/siteApiClient')
|
||||||
|
|
||||||
|
// Public command — no default_member_permissions restriction. Read-only:
|
||||||
|
// searches wiki titles/content and links to the best match. Never posts to or
|
||||||
|
// edits the wiki. Category-scoped search (spec's optional "/wiki spells
|
||||||
|
// fireball") is deferred — the site's public search endpoint currently
|
||||||
|
// ignores category filters whenever a text query is given.
|
||||||
|
function siteOrigin() {
|
||||||
|
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||||
|
return new URL(base).origin
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'wiki',
|
||||||
|
description: 'Search the wiki.',
|
||||||
|
options: [{ name: 'query', description: 'What to search for', type: ApplicationCommandOptionType.String, required: true }],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const query = interaction.options.getString('query', true)
|
||||||
|
await interaction.deferReply()
|
||||||
|
|
||||||
|
const result = await siteApiClient.searchWiki(query)
|
||||||
|
if (result.maintenance) {
|
||||||
|
await interaction.editReply({ content: `The wiki is unavailable right now: ${result.message || 'maintenance mode'}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.ok || !result.data || result.data.length === 0) {
|
||||||
|
await interaction.editReply({ content: `No wiki results for "${query}".` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = result.data[0]
|
||||||
|
const url = `${siteOrigin()}/wiki/${best.slug}`
|
||||||
|
const content = best.excerpt ? `**${best.title}**\n${best.excerpt}\n${url}` : `**${best.title}**\n${url}`
|
||||||
|
await interaction.editReply({ content })
|
||||||
|
},
|
||||||
|
}
|
||||||
144
bot/src/discord/discordManager.js
Normal file
144
bot/src/discord/discordManager.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// Owns the single discord.js Client instance for this process: lifecycle
|
||||||
|
// (start/stop/status) and slash-command registration/dispatch. Command
|
||||||
|
// definitions themselves live in ./commands — this file only wires them up.
|
||||||
|
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
|
||||||
|
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
const commands = require('./commands')
|
||||||
|
const messageFilter = require('./messageFilter')
|
||||||
|
const scheduler = require('../scheduler/scheduler')
|
||||||
|
const roleMenuHandler = require('./roleMenuHandler')
|
||||||
|
const { handleGuildMemberAdd } = require('./guildMemberAdd')
|
||||||
|
const { handleGuildMemberRemove } = require('./guildMemberRemove')
|
||||||
|
const inviteTracker = require('./inviteTracker')
|
||||||
|
const tempRoleSweeper = require('../roles/tempRoleSweeper')
|
||||||
|
const inviteScheduler = require('../invites/inviteScheduler')
|
||||||
|
|
||||||
|
const log = createLogger('discord')
|
||||||
|
|
||||||
|
let client = null
|
||||||
|
let guildId = null
|
||||||
|
let status = 'disconnected' // disconnected | connecting | connected | error
|
||||||
|
let statusDetail = null
|
||||||
|
let lastConnectedAt = null
|
||||||
|
|
||||||
|
async function registerCommands(applicationId, targetGuildId) {
|
||||||
|
const rest = new REST({ version: '10' }).setToken(client.token)
|
||||||
|
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
|
||||||
|
body: commands.all.map((c) => c.data),
|
||||||
|
})
|
||||||
|
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!client) {
|
||||||
|
status = 'disconnected'
|
||||||
|
statusDetail = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduler.stop()
|
||||||
|
tempRoleSweeper.stop()
|
||||||
|
inviteScheduler.stop()
|
||||||
|
try {
|
||||||
|
await client.destroy()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('error while destroying client', { message: err.message })
|
||||||
|
}
|
||||||
|
client = null
|
||||||
|
status = 'disconnected'
|
||||||
|
statusDetail = null
|
||||||
|
log.info('discord client disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
// start({ token, guildId }) — (re)connects. Always stops any existing client
|
||||||
|
// first so re-saving config or toggling Enabled off/on is idempotent.
|
||||||
|
async function start({ token, guildId: gid }) {
|
||||||
|
await stop()
|
||||||
|
guildId = gid
|
||||||
|
status = 'connecting'
|
||||||
|
statusDetail = null
|
||||||
|
|
||||||
|
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
|
||||||
|
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
|
||||||
|
// in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
|
||||||
|
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
|
||||||
|
client = new Client({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
|
GatewayIntentBits.GuildInvites,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
client.once('ready', async () => {
|
||||||
|
try {
|
||||||
|
await registerCommands(client.application.id, guildId)
|
||||||
|
await scheduler.start(client)
|
||||||
|
tempRoleSweeper.start(client)
|
||||||
|
inviteScheduler.start(client, guildId)
|
||||||
|
await inviteTracker.prime(client, guildId)
|
||||||
|
status = 'connected'
|
||||||
|
statusDetail = null
|
||||||
|
lastConnectedAt = new Date()
|
||||||
|
log.info('discord client ready', { user: client.user?.tag, guildId })
|
||||||
|
} catch (err) {
|
||||||
|
status = 'error'
|
||||||
|
statusDetail = `startup failed: ${err.message}`
|
||||||
|
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('interactionCreate', async (interaction) => {
|
||||||
|
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||||
|
if (!interaction.isChatInputCommand()) return
|
||||||
|
const command = commands.get(interaction.commandName)
|
||||||
|
if (!command) return
|
||||||
|
try {
|
||||||
|
await command.execute(interaction)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('command execution failed', { command: interaction.commandName, message: err.message })
|
||||||
|
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
|
||||||
|
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
|
||||||
|
else await interaction.reply(payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('messageCreate', messageFilter.handleMessageCreate)
|
||||||
|
client.on('guildMemberAdd', handleGuildMemberAdd)
|
||||||
|
client.on('guildMemberRemove', handleGuildMemberRemove)
|
||||||
|
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
|
||||||
|
client.on('inviteCreate', inviteTracker.onInviteCreate)
|
||||||
|
client.on('inviteDelete', inviteTracker.onInviteDelete)
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
status = 'error'
|
||||||
|
statusDetail = err.message
|
||||||
|
log.error('discord client error', { message: err.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(token)
|
||||||
|
} catch (err) {
|
||||||
|
status = 'error'
|
||||||
|
statusDetail = err.message
|
||||||
|
client = null
|
||||||
|
log.error('discord login failed', { message: err.message })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return { status, statusDetail, guildId, lastConnectedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For code that needs the live client + which guild it's connected to (the
|
||||||
|
// /internal/announce handler, slash commands already get both from the
|
||||||
|
// interaction itself so they don't need this). Returns null if disconnected.
|
||||||
|
function getConnection() {
|
||||||
|
if (!client || status !== 'connected') return null
|
||||||
|
return { client, guildId }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, getStatus, getConnection }
|
||||||
45
bot/src/discord/guildMemberAdd.js
Normal file
45
bot/src/discord/guildMemberAdd.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Member join handling: record the join event (with best-effort invite
|
||||||
|
// attribution, Phase 6b) then apply the configured auto-role. Requires the
|
||||||
|
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
|
||||||
|
// and, for invite attribution, the GuildInvites intent.
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const memberEvents = require('../model/memberEvents')
|
||||||
|
const inviteTracker = require('./inviteTracker')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('members')
|
||||||
|
|
||||||
|
async function handleGuildMemberAdd(member) {
|
||||||
|
// Attribute the invite first (diffs the invite-use cache), then record the join.
|
||||||
|
// Both are best-effort — a failure here must never block the auto-role below.
|
||||||
|
let invite = { code: null, inviterId: null, inviterTag: null }
|
||||||
|
try {
|
||||||
|
invite = await inviteTracker.attribute(member)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('invite attribution threw', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await memberEvents.record({
|
||||||
|
guildId: member.guild.id,
|
||||||
|
eventType: 'join',
|
||||||
|
discordUserId: member.id,
|
||||||
|
username: member.user?.tag,
|
||||||
|
inviteCode: invite.code,
|
||||||
|
inviterId: invite.inviterId,
|
||||||
|
inviterTag: invite.inviterTag,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('member join record failed', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
|
||||||
|
if (!roleId) return
|
||||||
|
await member.roles.add(roleId)
|
||||||
|
log.info('auto-role assigned', { userId: member.id, roleId })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('auto-role assignment failed', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleGuildMemberAdd }
|
||||||
23
bot/src/discord/guildMemberRemove.js
Normal file
23
bot/src/discord/guildMemberRemove.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Member leave handling (Phase 6b): record a leave event for the dashboard's
|
||||||
|
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
|
||||||
|
// distinguish them on this event, and the mod-action (if any) is logged
|
||||||
|
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
|
||||||
|
const memberEvents = require('../model/memberEvents')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('members')
|
||||||
|
|
||||||
|
async function handleGuildMemberRemove(member) {
|
||||||
|
try {
|
||||||
|
await memberEvents.record({
|
||||||
|
guildId: member.guild.id,
|
||||||
|
eventType: 'leave',
|
||||||
|
discordUserId: member.id,
|
||||||
|
username: member.user?.tag,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('member leave record failed', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleGuildMemberRemove }
|
||||||
74
bot/src/discord/inviteTracker.js
Normal file
74
bot/src/discord/inviteTracker.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
|
||||||
|
// which invite a member used, so the standard approach is to keep a cache of
|
||||||
|
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
|
||||||
|
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
|
||||||
|
// already creates/deletes invites, so it has the permission). All calls are
|
||||||
|
// best-effort: any failure just yields a null attribution and the join is still
|
||||||
|
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('invites')
|
||||||
|
|
||||||
|
// guildId -> Map<inviteCode, uses>
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
async function snapshot(guild) {
|
||||||
|
const map = new Map()
|
||||||
|
const invites = await guild.invites.fetch()
|
||||||
|
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the cache for a guild (call once the client is ready).
|
||||||
|
async function prime(client, guildId) {
|
||||||
|
try {
|
||||||
|
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
|
||||||
|
cache.set(guildId, await snapshot(guild))
|
||||||
|
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInviteCreate(invite) {
|
||||||
|
if (!invite.guild) return
|
||||||
|
const g = cache.get(invite.guild.id) || new Map()
|
||||||
|
g.set(invite.code, invite.uses || 0)
|
||||||
|
cache.set(invite.guild.id, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInviteDelete(invite) {
|
||||||
|
if (!invite.guild) return
|
||||||
|
const g = cache.get(invite.guild.id)
|
||||||
|
if (g) g.delete(invite.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff current invite uses against the cached snapshot to find which invite the
|
||||||
|
// joining member used, then refresh the cache. Returns { code, inviterId,
|
||||||
|
// inviterTag } with nulls when it can't be determined.
|
||||||
|
async function attribute(member) {
|
||||||
|
const empty = { code: null, inviterId: null, inviterTag: null }
|
||||||
|
try {
|
||||||
|
const guild = member.guild
|
||||||
|
const before = cache.get(guild.id) || new Map()
|
||||||
|
const current = await guild.invites.fetch()
|
||||||
|
|
||||||
|
let found = empty
|
||||||
|
for (const inv of current.values()) {
|
||||||
|
const prev = before.get(inv.code) || 0
|
||||||
|
if ((inv.uses || 0) > prev && found === empty) {
|
||||||
|
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map()
|
||||||
|
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
|
||||||
|
cache.set(guild.id, next)
|
||||||
|
return found
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('invite attribution failed', { message: err.message })
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }
|
||||||
137
bot/src/discord/messageFilter.js
Normal file
137
bot/src/discord/messageFilter.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// messageCreate orchestration: allowlist bypass -> invite link -> banned word
|
||||||
|
// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn
|
||||||
|
// (no severity tiers for those, unlike the word filter) — kept simple per the
|
||||||
|
// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute
|
||||||
|
// duration; per-severity-configurable durations are a future refinement.
|
||||||
|
const filterCache = require('../filter/filterCache')
|
||||||
|
const { findMatch } = require('../filter/normalize')
|
||||||
|
const inviteFilter = require('../filter/inviteFilter')
|
||||||
|
const spamFilter = require('../filter/spamFilter')
|
||||||
|
const warnings = require('../model/warnings')
|
||||||
|
const filterHits = require('../model/filterHits')
|
||||||
|
const spamHits = require('../model/spamHits')
|
||||||
|
const modLog = require('./modLog')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('filter')
|
||||||
|
|
||||||
|
const FILTER_MUTE_SECONDS = 600 // 10 minutes
|
||||||
|
|
||||||
|
function botActor(client) {
|
||||||
|
return { id: client.user.id, tag: client.user.tag }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
|
||||||
|
// break the moderation action it accompanies, so failures are swallowed+logged.
|
||||||
|
async function recordFilterHit(message, hitType, matched, actionTaken) {
|
||||||
|
try {
|
||||||
|
await filterHits.record({
|
||||||
|
guildId: message.guildId,
|
||||||
|
hitType,
|
||||||
|
discordUserId: message.author.id,
|
||||||
|
username: message.author.tag,
|
||||||
|
channelId: message.channelId,
|
||||||
|
matched,
|
||||||
|
actionTaken,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('filter hit record failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordSpamHit(message, spamType) {
|
||||||
|
try {
|
||||||
|
await spamHits.record({
|
||||||
|
guildId: message.guildId,
|
||||||
|
spamType,
|
||||||
|
discordUserId: message.author.id,
|
||||||
|
username: message.author.tag,
|
||||||
|
channelId: message.channelId,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('spam hit record failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
|
||||||
|
// effect (records this message's timestamp) so it must be evaluated first, and
|
||||||
|
// exactly once — mirroring the original OR-order.
|
||||||
|
function detectSpam(message) {
|
||||||
|
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
|
||||||
|
if (spamFilter.isMassMention(message)) return 'mass_mention'
|
||||||
|
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isBypassed(message, cache) {
|
||||||
|
if (cache.allowChannels.has(message.channelId)) return true
|
||||||
|
const memberRoles = message.member ? message.member.roles.cache : null
|
||||||
|
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyWarnAction(message, reason) {
|
||||||
|
const staff = botActor(message.client)
|
||||||
|
await warnings.add({
|
||||||
|
guildId: message.guildId,
|
||||||
|
targetUserId: message.author.id,
|
||||||
|
targetTag: message.author.tag,
|
||||||
|
staffUserId: staff.id,
|
||||||
|
staffTag: staff.tag,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMuteAction(message, reason) {
|
||||||
|
const staff = botActor(message.client)
|
||||||
|
if (message.member && message.member.moderatable) {
|
||||||
|
await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason)
|
||||||
|
}
|
||||||
|
await modLog.record({
|
||||||
|
client: message.client,
|
||||||
|
guildId: message.guildId,
|
||||||
|
actionType: 'mute',
|
||||||
|
target: message.author,
|
||||||
|
staffUser: staff,
|
||||||
|
reason,
|
||||||
|
durationSeconds: FILTER_MUTE_SECONDS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessageCreate(message) {
|
||||||
|
if (message.author.bot || !message.guildId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = await filterCache.getOrLoad(message.guildId)
|
||||||
|
if (await isBypassed(message, cache)) return
|
||||||
|
|
||||||
|
const foreignCode = await inviteFilter.foreignInviteCode(message)
|
||||||
|
if (foreignCode) {
|
||||||
|
await message.delete().catch(() => {})
|
||||||
|
await recordFilterHit(message, 'invite', foreignCode, 'warn')
|
||||||
|
await applyWarnAction(message, 'Posted a Discord invite link')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = findMatch(message.content, cache.words)
|
||||||
|
if (match) {
|
||||||
|
await message.delete().catch(() => {})
|
||||||
|
await recordFilterHit(message, 'word', match.word, match.severity)
|
||||||
|
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
|
||||||
|
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const spamType = detectSpam(message)
|
||||||
|
if (spamType) {
|
||||||
|
await message.delete().catch(() => {})
|
||||||
|
await recordSpamHit(message, spamType)
|
||||||
|
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('messageFilter failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleMessageCreate }
|
||||||
53
bot/src/discord/modLog.js
Normal file
53
bot/src/discord/modLog.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Shared by every moderation command (ban/kick/mute/warn): writes the audit
|
||||||
|
// row and posts the embed to the configured mod-log channel. Takes `client`
|
||||||
|
// as a parameter (from interaction.client) rather than importing
|
||||||
|
// discordManager directly, to avoid a require cycle (discordManager -> commands
|
||||||
|
// -> modLog -> discordManager).
|
||||||
|
const { EmbedBuilder } = require('discord.js')
|
||||||
|
|
||||||
|
const db = require('../db')
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('modlog')
|
||||||
|
|
||||||
|
const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 }
|
||||||
|
|
||||||
|
async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null],
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channelId = await guildConfig.getModLogChannelId(guildId)
|
||||||
|
if (!channelId) return
|
||||||
|
const channel = await client.channels.fetch(channelId)
|
||||||
|
if (!channel || !channel.isTextBased()) return
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(COLOR[actionType] || 0x9aa5b1)
|
||||||
|
.setTitle(actionType.toUpperCase())
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true },
|
||||||
|
{ name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true },
|
||||||
|
)
|
||||||
|
.setTimestamp()
|
||||||
|
if (reason) embed.addFields({ name: 'Reason', value: reason })
|
||||||
|
if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true })
|
||||||
|
|
||||||
|
await channel.send({ embeds: [embed] })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to post mod-log embed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||||||
|
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||||
|
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
26
bot/src/discord/newsAnnounce.js
Normal file
26
bot/src/discord/newsAnnounce.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Shared by the /internal/announce webhook (site publishes a news post) and
|
||||||
|
// the manual /announce command (staff re-posts/boosts an existing one) — so
|
||||||
|
// both paths produce an identical embed.
|
||||||
|
const { EmbedBuilder } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('news')
|
||||||
|
|
||||||
|
async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) {
|
||||||
|
const channelId = await guildConfig.getNewsChannelId(guildId)
|
||||||
|
if (!channelId) throw new Error('No news channel configured — set one with /news first.')
|
||||||
|
|
||||||
|
const channel = await client.channels.fetch(channelId)
|
||||||
|
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url)
|
||||||
|
if (excerpt) embed.setDescription(excerpt)
|
||||||
|
if (imageUrl) embed.setImage(imageUrl)
|
||||||
|
|
||||||
|
await channel.send({ embeds: [embed] })
|
||||||
|
log.info('news announced', { title, channelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { postAnnounce }
|
||||||
41
bot/src/discord/roleMenuHandler.js
Normal file
41
bot/src/discord/roleMenuHandler.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Button-based self-assignable role menus. customId is `rolemenu:<roleId>` —
|
||||||
|
// the message's own id (not known until after it's sent, so it can't be
|
||||||
|
// embedded in the customId itself) is instead used to look up the tracked
|
||||||
|
// role_menus row and confirm the clicked roleId is really part of that
|
||||||
|
// menu's mapping, so a stale/foreign button can't toggle an untracked role.
|
||||||
|
const roleMenus = require('../model/roleMenus')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('rolemenu')
|
||||||
|
|
||||||
|
const PREFIX = 'rolemenu:'
|
||||||
|
|
||||||
|
// Returns true if this handler owned the interaction (caller should stop
|
||||||
|
// looking for another handler), false if it's not a role-menu button at all.
|
||||||
|
async function handleInteraction(interaction) {
|
||||||
|
if (!interaction.isButton() || !interaction.customId.startsWith(PREFIX)) return false
|
||||||
|
|
||||||
|
const roleId = interaction.customId.slice(PREFIX.length)
|
||||||
|
try {
|
||||||
|
const menu = await roleMenus.getByMessageId(interaction.message.id)
|
||||||
|
if (!menu || !menu.mapping.some((m) => m.roleId === roleId)) {
|
||||||
|
await interaction.reply({ content: 'This role menu is no longer valid.', ephemeral: true })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.member
|
||||||
|
if (member.roles.cache.has(roleId)) {
|
||||||
|
await member.roles.remove(roleId)
|
||||||
|
await interaction.reply({ content: `Removed <@&${roleId}>.`, ephemeral: true })
|
||||||
|
} else {
|
||||||
|
await member.roles.add(roleId)
|
||||||
|
await interaction.reply({ content: `Added <@&${roleId}>.`, ephemeral: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('role menu toggle failed', { message: err.message })
|
||||||
|
await interaction.reply({ content: 'Something went wrong toggling that role.', ephemeral: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleInteraction }
|
||||||
31
bot/src/filter/filterCache.js
Normal file
31
bot/src/filter/filterCache.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// In-memory per-guild filter state (word list + allowlist), loaded at startup
|
||||||
|
// and refreshed on config change — the messageCreate handler runs on every
|
||||||
|
// message, so it must never hit the DB per message (per the spec's
|
||||||
|
// performance note).
|
||||||
|
const filterWords = require('../model/filterWords')
|
||||||
|
const filterAllowlist = require('../model/filterAllowlist')
|
||||||
|
|
||||||
|
const cache = new Map() // guildId -> { words, allowRoles: Set, allowChannels: Set }
|
||||||
|
|
||||||
|
async function load(guildId) {
|
||||||
|
const [words, roles, channels] = await Promise.all([
|
||||||
|
filterWords.list(guildId),
|
||||||
|
filterAllowlist.getRoles(guildId),
|
||||||
|
filterAllowlist.getChannels(guildId),
|
||||||
|
])
|
||||||
|
const entry = { words, allowRoles: new Set(roles), allowChannels: new Set(channels) }
|
||||||
|
cache.set(guildId, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-loads on first access per guild (e.g. the first message after boot).
|
||||||
|
async function getOrLoad(guildId) {
|
||||||
|
return cache.get(guildId) || load(guildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by /filter and /filterallow after any mutation.
|
||||||
|
function refresh(guildId) {
|
||||||
|
return load(guildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getOrLoad, refresh }
|
||||||
26
bot/src/filter/inviteFilter.js
Normal file
26
bot/src/filter/inviteFilter.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Detects Discord invite links and blocks any that don't resolve to the
|
||||||
|
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
|
||||||
|
// (expired/invalid/vanity-only) is treated as foreign too — safer default
|
||||||
|
// than silently letting an unresolvable link through.
|
||||||
|
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
|
||||||
|
|
||||||
|
// Returns the first foreign (or unresolvable) invite code found in the message,
|
||||||
|
// or null if the message contains no foreign invites. Returning the code (rather
|
||||||
|
// than a bare boolean) lets the caller record which invite was blocked.
|
||||||
|
async function foreignInviteCode(message) {
|
||||||
|
const matches = [...message.content.matchAll(INVITE_REGEX)]
|
||||||
|
if (matches.length === 0) return null
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const code = match[1]
|
||||||
|
try {
|
||||||
|
const invite = await message.client.fetchInvite(code)
|
||||||
|
if (invite.guild?.id !== message.guildId) return code
|
||||||
|
} catch {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { foreignInviteCode }
|
||||||
33
bot/src/filter/normalize.js
Normal file
33
bot/src/filter/normalize.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Basic obfuscation-resistant normalization for the word filter: lowercase,
|
||||||
|
// common leetspeak substitutions, and collapsing 3+ repeated characters
|
||||||
|
// ("sooooo" -> "so") to one. Deliberately simple per the spec ("start simple,
|
||||||
|
// leave room to tighten later") — spaced-out letters ("b a d") and more exotic
|
||||||
|
// unicode lookalikes aren't handled yet.
|
||||||
|
const SUBS = { 4: 'a', '@': 'a', 3: 'e', 1: 'i', '!': 'i', 0: 'o', $: 's', 5: 's', 7: 't' }
|
||||||
|
const SUB_CHARS = /[4@31!05$7]/g
|
||||||
|
|
||||||
|
function normalize(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(SUB_CHARS, (ch) => SUBS[ch] || ch)
|
||||||
|
.replace(/(.)\1{2,}/g, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-boundary match against already-normalized text. `word` is normalized
|
||||||
|
// here too, so callers can pass the raw stored value.
|
||||||
|
function matches(normalizedText, word) {
|
||||||
|
const pattern = new RegExp(`\\b${escapeRegex(normalize(word))}\\b`, 'i')
|
||||||
|
return pattern.test(normalizedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the first matching filter_words row ({word, severity}) or null.
|
||||||
|
function findMatch(content, words) {
|
||||||
|
const normalizedText = normalize(content)
|
||||||
|
return words.find((w) => matches(normalizedText, w.word)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalize, matches, findMatch }
|
||||||
42
bot/src/filter/spamFilter.js
Normal file
42
bot/src/filter/spamFilter.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Basic in-memory spam/rate-limit detection. Per-user message-rate tracking is
|
||||||
|
// the only stateful piece here (mass-mention/mass-emoji are per-message
|
||||||
|
// counts) — kept in memory rather than the DB since this runs on every
|
||||||
|
// message and needs to be fast.
|
||||||
|
const RATE_LIMIT_COUNT = 5
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 5000
|
||||||
|
const MENTION_THRESHOLD = 5
|
||||||
|
const EMOJI_THRESHOLD = 10
|
||||||
|
const SWEEP_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
const history = new Map() // `${guildId}:${userId}` -> timestamps[]
|
||||||
|
|
||||||
|
function isRateLimited(guildId, userId) {
|
||||||
|
const key = `${guildId}:${userId}`
|
||||||
|
const now = Date.now()
|
||||||
|
const timestamps = (history.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
|
||||||
|
timestamps.push(now)
|
||||||
|
history.set(key, timestamps)
|
||||||
|
return timestamps.length > RATE_LIMIT_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMassMention(message) {
|
||||||
|
return message.mentions.users.size + message.mentions.roles.size > MENTION_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_REGEX = /<a?:\w+:\d+>|\p{Extended_Pictographic}/gu
|
||||||
|
|
||||||
|
function isMassEmoji(content) {
|
||||||
|
const count = (content.match(EMOJI_REGEX) || []).length
|
||||||
|
return count > EMOJI_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup so `history` doesn't grow unbounded over a long-running
|
||||||
|
// process — drops any key with no recent activity.
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, timestamps] of history) {
|
||||||
|
if (timestamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) history.delete(key)
|
||||||
|
}
|
||||||
|
}, SWEEP_INTERVAL_MS).unref()
|
||||||
|
|
||||||
|
module.exports = { isRateLimited, isMassMention, isMassEmoji }
|
||||||
50
bot/src/internal/internal.controller.js
Normal file
50
bot/src/internal/internal.controller.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const discordManager = require('../discord/discordManager')
|
||||||
|
const newsAnnounce = require('../discord/newsAnnounce')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('internal')
|
||||||
|
|
||||||
|
// POST /internal/config — called by the main server right after an admin
|
||||||
|
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
|
||||||
|
// (via a GET to the server for the current config, then this same start/stop
|
||||||
|
// logic locally). Body: { token, guildId, enabled }.
|
||||||
|
async function setConfig(req, res) {
|
||||||
|
const { token, guildId, enabled } = req.body || {}
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
if (!token || !guildId) {
|
||||||
|
return res.status(400).json({ message: 'token and guildId are required when enabled' })
|
||||||
|
}
|
||||||
|
await discordManager.start({ token, guildId })
|
||||||
|
} else {
|
||||||
|
await discordManager.stop()
|
||||||
|
}
|
||||||
|
return res.json(discordManager.getStatus())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('setConfig failed', { message: err.message })
|
||||||
|
// Still 200 with an error status — the caller (admin panel) should surface
|
||||||
|
// discordManager's status/statusDetail rather than treat this as a 5xx.
|
||||||
|
return res.json(discordManager.getStatus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /internal/status — live connection state, polled by the admin panel.
|
||||||
|
function getStatusHandler(req, res) {
|
||||||
|
return res.json(discordManager.getStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /internal/announce — called by the main server right after a news
|
||||||
|
// post is published. Body: { title, excerpt, url, imageUrl }.
|
||||||
|
async function announce(req, res) {
|
||||||
|
const connection = discordManager.getConnection()
|
||||||
|
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||||
|
try {
|
||||||
|
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
|
||||||
|
return res.json({ posted: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('announce failed', { message: err.message })
|
||||||
|
return res.status(400).json({ message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setConfig, getStatus: getStatusHandler, announce }
|
||||||
14
bot/src/internal/internal.routes.js
Normal file
14
bot/src/internal/internal.routes.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const requireInternalKey = require('./requireInternalKey')
|
||||||
|
const ctrl = require('./internal.controller')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use(requireInternalKey)
|
||||||
|
|
||||||
|
router.post('/config', ctrl.setConfig)
|
||||||
|
router.get('/status', ctrl.getStatus)
|
||||||
|
router.post('/announce', ctrl.announce)
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
19
bot/src/internal/requireInternalKey.js
Normal file
19
bot/src/internal/requireInternalKey.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
|
||||||
|
// server, over the private compose network — never expose this route through
|
||||||
|
// the public reverse proxy. Timing-safe compare so response time can't be used
|
||||||
|
// to brute-force the shared secret one byte at a time.
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
function requireInternalKey(req, res, next) {
|
||||||
|
const expected = process.env.BOT_INTERNAL_KEY || ''
|
||||||
|
const provided = req.get('X-Internal-Key') || ''
|
||||||
|
|
||||||
|
const a = Buffer.from(expected)
|
||||||
|
const b = Buffer.from(provided)
|
||||||
|
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||||
|
|
||||||
|
if (!match) return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = requireInternalKey
|
||||||
37
bot/src/invites/inviteRotator.js
Normal file
37
bot/src/invites/inviteRotator.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Shared by both /invite rotate and the weekly cron job (inviteScheduler.js)
|
||||||
|
// so manual and automatic rotations log identically. maxAge is set to match
|
||||||
|
// the rotation cadence as defense-in-depth: if the scheduled rotation were
|
||||||
|
// ever to silently stop running, the invite still expires on its own instead
|
||||||
|
// of staying live forever.
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const inviteLog = require('../model/inviteLog')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('invites')
|
||||||
|
|
||||||
|
const ROTATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 // 7 days
|
||||||
|
|
||||||
|
async function rotate(client, guildId, { triggeredBy, triggeredByTag } = {}) {
|
||||||
|
const channelId = await guildConfig.getInviteChannelId(guildId)
|
||||||
|
if (!channelId) throw new Error('No invite channel configured — set one with /invite channel first.')
|
||||||
|
|
||||||
|
const channel = await client.channels.fetch(channelId)
|
||||||
|
if (!channel || !channel.isTextBased()) throw new Error('Configured invite channel is missing or not text-based.')
|
||||||
|
|
||||||
|
const current = await inviteLog.getCurrent(guildId)
|
||||||
|
if (current) {
|
||||||
|
try {
|
||||||
|
await channel.guild.invites.delete(current.invite_code, 'Invite rotation')
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to revoke previous invite (may already be gone)', { message: err.message })
|
||||||
|
}
|
||||||
|
await inviteLog.markRevoked(current.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const invite = await channel.createInvite({ maxAge: ROTATION_MAX_AGE_SECONDS, unique: true, reason: 'Invite rotation' })
|
||||||
|
await inviteLog.record({ guildId, channelId, inviteCode: invite.code, triggeredBy, triggeredByTag })
|
||||||
|
log.info('invite rotated', { code: invite.code, triggeredBy: triggeredByTag || 'automatic (scheduled)' })
|
||||||
|
return invite
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { rotate }
|
||||||
31
bot/src/invites/inviteScheduler.js
Normal file
31
bot/src/invites/inviteScheduler.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Weekly automatic invite rotation (Sundays at midnight). A missing invite
|
||||||
|
// channel config just skips quietly (warn-logged) — most guilds won't set
|
||||||
|
// this up on day one, and that shouldn't spam errors every week until they do.
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const inviteRotator = require('./inviteRotator')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('invites')
|
||||||
|
|
||||||
|
let task = null
|
||||||
|
|
||||||
|
function start(client, guildId) {
|
||||||
|
task = cron.schedule('0 0 * * 0', async () => {
|
||||||
|
try {
|
||||||
|
await inviteRotator.rotate(client, guildId, {})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('scheduled invite rotation skipped', { message: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
log.info('invite rotation scheduler started')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (task) {
|
||||||
|
task.stop()
|
||||||
|
task = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop }
|
||||||
40
bot/src/model/filterAllowlist.js
Normal file
40
bot/src/model/filterAllowlist.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Roles/channels that bypass word/invite/spam filtering entirely (staff roles,
|
||||||
|
// bot-commands channels, etc.). Stored as CSV in guild_config rather than a
|
||||||
|
// separate table — short, rarely-changed lists.
|
||||||
|
const guildConfig = require('./guildConfig')
|
||||||
|
|
||||||
|
const ROLES_KEY = 'filter_allow_roles'
|
||||||
|
const CHANNELS_KEY = 'filter_allow_channels'
|
||||||
|
|
||||||
|
function parseCsv(value) {
|
||||||
|
return value ? value.split(',').filter(Boolean) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoles(guildId) {
|
||||||
|
return parseCsv(await guildConfig.get(guildId, ROLES_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChannels(guildId) {
|
||||||
|
return parseCsv(await guildConfig.get(guildId, CHANNELS_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle: adds the id if absent, removes it if present. Returns the new state (true = now allowed).
|
||||||
|
async function toggleRole(guildId, roleId) {
|
||||||
|
const roles = await getRoles(guildId)
|
||||||
|
const idx = roles.indexOf(roleId)
|
||||||
|
if (idx === -1) roles.push(roleId)
|
||||||
|
else roles.splice(idx, 1)
|
||||||
|
await guildConfig.set(guildId, ROLES_KEY, roles.join(','))
|
||||||
|
return idx === -1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleChannel(guildId, channelId) {
|
||||||
|
const channels = await getChannels(guildId)
|
||||||
|
const idx = channels.indexOf(channelId)
|
||||||
|
if (idx === -1) channels.push(channelId)
|
||||||
|
else channels.splice(idx, 1)
|
||||||
|
await guildConfig.set(guildId, CHANNELS_KEY, channels.join(','))
|
||||||
|
return idx === -1
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getRoles, getChannels, toggleRole, toggleChannel }
|
||||||
15
bot/src/model/filterHits.js
Normal file
15
bot/src/model/filterHits.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
|
||||||
|
// word filter or foreign-invite filter deletes a message. mod_actions still
|
||||||
|
// records the resulting warn/mute separately. Schema: server/db/schema.sql
|
||||||
|
// (filter_hits).
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
22
bot/src/model/filterWords.js
Normal file
22
bot/src/model/filterWords.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function add({ guildId, word, severity, addedBy, addedByTag }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO filter_words (guild_id, word, severity, added_by, added_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE severity = VALUES(severity), added_by = VALUES(added_by), added_by_tag = VALUES(added_by_tag)`,
|
||||||
|
[guildId, word.toLowerCase(), severity || 'delete', addedBy || null, addedByTag || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if a row was actually removed.
|
||||||
|
async function remove(guildId, word) {
|
||||||
|
const res = await db.query('DELETE FROM filter_words WHERE guild_id = ? AND word = ?', [guildId, word.toLowerCase()])
|
||||||
|
return Number(res.affectedRows || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(guildId) {
|
||||||
|
return db.query('SELECT word, severity FROM filter_words WHERE guild_id = ? ORDER BY word ASC', [guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, remove, list }
|
||||||
47
bot/src/model/guildConfig.js
Normal file
47
bot/src/model/guildConfig.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Per-guild key/value config the bot owns (see guild_config in
|
||||||
|
// server/db/schema.sql). Generic get/set now; filters/schedules/role-menu
|
||||||
|
// config reuses this same table in later phases.
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
const MOD_LOG_CHANNEL_KEY = 'mod_log_channel_id'
|
||||||
|
const AUTO_ROLE_KEY = 'auto_role_id'
|
||||||
|
const INVITE_CHANNEL_KEY = 'invite_channel_id'
|
||||||
|
const NEWS_CHANNEL_KEY = 'news_channel_id'
|
||||||
|
|
||||||
|
async function get(guildId, key) {
|
||||||
|
const rows = await db.query('SELECT value FROM guild_config WHERE guild_id = ? AND `key` = ? LIMIT 1', [guildId, key])
|
||||||
|
return rows[0] ? rows[0].value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function set(guildId, key, value) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO guild_config (guild_id, \`key\`, value) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
||||||
|
[guildId, key, value],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModLogChannelId = (guildId) => get(guildId, MOD_LOG_CHANNEL_KEY)
|
||||||
|
const setModLogChannelId = (guildId, channelId) => set(guildId, MOD_LOG_CHANNEL_KEY, channelId)
|
||||||
|
|
||||||
|
const getAutoRoleId = (guildId) => get(guildId, AUTO_ROLE_KEY)
|
||||||
|
const setAutoRoleId = (guildId, roleId) => set(guildId, AUTO_ROLE_KEY, roleId)
|
||||||
|
|
||||||
|
const getInviteChannelId = (guildId) => get(guildId, INVITE_CHANNEL_KEY)
|
||||||
|
const setInviteChannelId = (guildId, channelId) => set(guildId, INVITE_CHANNEL_KEY, channelId)
|
||||||
|
|
||||||
|
const getNewsChannelId = (guildId) => get(guildId, NEWS_CHANNEL_KEY)
|
||||||
|
const setNewsChannelId = (guildId, channelId) => set(guildId, NEWS_CHANNEL_KEY, channelId)
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
getModLogChannelId,
|
||||||
|
setModLogChannelId,
|
||||||
|
getAutoRoleId,
|
||||||
|
setAutoRoleId,
|
||||||
|
getInviteChannelId,
|
||||||
|
setInviteChannelId,
|
||||||
|
getNewsChannelId,
|
||||||
|
setNewsChannelId,
|
||||||
|
}
|
||||||
29
bot/src/model/inviteLog.js
Normal file
29
bot/src/model/inviteLog.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, channelId, inviteCode, triggeredBy, triggeredByTag }) {
|
||||||
|
const res = await db.query(
|
||||||
|
`INSERT INTO invite_log (guild_id, channel_id, invite_code, triggered_by, triggered_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, inviteCode, triggeredBy || null, triggeredByTag || null],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active (not-yet-revoked) invite for a guild, if any.
|
||||||
|
async function getCurrent(guildId) {
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT * FROM invite_log WHERE guild_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
|
||||||
|
[guildId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRevoked(id) {
|
||||||
|
await db.query('UPDATE invite_log SET revoked_at = NOW() WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(guildId, limit = 10) {
|
||||||
|
return db.query('SELECT * FROM invite_log WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', [guildId, limit])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record, getCurrent, markRevoked, list }
|
||||||
14
bot/src/model/memberEvents.js
Normal file
14
bot/src/model/memberEvents.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
|
||||||
|
// the moderation dashboard's members feed + invite-usage view. Schema in
|
||||||
|
// server/db/schema.sql (member_events).
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
17
bot/src/model/roleMenus.js
Normal file
17
bot/src/model/roleMenus.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function add({ guildId, channelId, messageId, mapping, createdBy }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO role_menus (guild_id, channel_id, message_id, mapping, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, messageId, JSON.stringify(mapping), createdBy || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByMessageId(messageId) {
|
||||||
|
const rows = await db.query('SELECT * FROM role_menus WHERE message_id = ? LIMIT 1', [messageId])
|
||||||
|
if (!rows[0]) return null
|
||||||
|
return { ...rows[0], mapping: JSON.parse(rows[0].mapping) }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, getByMessageId }
|
||||||
57
bot/src/model/scheduledMessages.js
Normal file
57
bot/src/model/scheduledMessages.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function addRecurring({ guildId, channelId, content, cronExpression, createdBy, createdByTag }) {
|
||||||
|
const res = await db.query(
|
||||||
|
`INSERT INTO scheduled_messages (guild_id, channel_id, content, cron_expression, created_by, created_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, content, cronExpression, createdBy || null, createdByTag || null],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOnce({ guildId, channelId, content, runAt, createdBy, createdByTag }) {
|
||||||
|
const res = await db.query(
|
||||||
|
`INSERT INTO scheduled_messages (guild_id, channel_id, content, run_at, created_by, created_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, content, runAt, createdBy || null, createdByTag || null],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if a row was actually removed (scoped to the guild so one
|
||||||
|
// guild can't remove another's rows).
|
||||||
|
async function remove(guildId, id) {
|
||||||
|
const res = await db.query('DELETE FROM scheduled_messages WHERE id = ? AND guild_id = ?', [id, guildId])
|
||||||
|
return Number(res.affectedRows || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(guildId) {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, channel_id, content, cron_expression, run_at, enabled, sent_at FROM scheduled_messages
|
||||||
|
WHERE guild_id = ? ORDER BY id ASC`,
|
||||||
|
[guildId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All enabled recurring rows across every guild the bot serves — v1 only
|
||||||
|
// ever has one, but the scheduler doesn't need to special-case that.
|
||||||
|
async function listEnabledRecurring() {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, guild_id, channel_id, content, cron_expression FROM scheduled_messages
|
||||||
|
WHERE cron_expression IS NOT NULL AND enabled = 1`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-off rows due to post right now.
|
||||||
|
async function listDueOneOff() {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, guild_id, channel_id, content FROM scheduled_messages
|
||||||
|
WHERE run_at IS NOT NULL AND sent_at IS NULL AND enabled = 1 AND run_at <= NOW()`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markSent(id) {
|
||||||
|
await db.query('UPDATE scheduled_messages SET sent_at = NOW() WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { addRecurring, addOnce, remove, list, listEnabledRecurring, listDueOneOff, markSent }
|
||||||
14
bot/src/model/spamHits.js
Normal file
14
bot/src/model/spamHits.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
|
||||||
|
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
|
||||||
|
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, spamType, discordUserId, username, channelId }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, spamType, discordUserId, username || null, channelId || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
26
bot/src/model/tempRoles.js
Normal file
26
bot/src/model/tempRoles.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
// Upsert — re-granting the same temp role refreshes its expiry instead of
|
||||||
|
// creating a duplicate row (see UNIQUE(guild,user,role) in schema.sql).
|
||||||
|
async function add({ guildId, userId, roleId, expiresAt, createdBy }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO temp_roles (guild_id, user_id, role_id, expires_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), created_by = VALUES(created_by)`,
|
||||||
|
[guildId, userId, roleId, expiresAt, createdBy || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(guildId, userId, roleId) {
|
||||||
|
await db.query('DELETE FROM temp_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?', [guildId, userId, roleId])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listExpired() {
|
||||||
|
return db.query('SELECT id, guild_id, user_id, role_id FROM temp_roles WHERE expires_at <= NOW()')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeById(id) {
|
||||||
|
await db.query('DELETE FROM temp_roles WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, remove, listExpired, removeById }
|
||||||
26
bot/src/model/warnings.js
Normal file
26
bot/src/model/warnings.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Standing warnings (separate from mod_actions so /warnings can list a
|
||||||
|
// user's active warnings). expires_at is always NULL for now — decay/escalation
|
||||||
|
// (e.g. "3 active warns -> auto-mute") is deferred past Phase 2, see
|
||||||
|
// warn.command.js.
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function add({ guildId, targetUserId, targetTag, staffUserId, staffTag, reason }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO warnings (guild_id, target_user_id, target_tag, staff_user_id, staff_tag, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, targetUserId, targetTag || null, staffUserId, staffTag || null, reason || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active = not expired. Every row is active today since expires_at is never
|
||||||
|
// set, but the query is written to already respect it once decay lands.
|
||||||
|
async function listActive(guildId, targetUserId) {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, reason, staff_tag, created_at FROM warnings
|
||||||
|
WHERE guild_id = ? AND target_user_id = ? AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[guildId, targetUserId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, listActive }
|
||||||
48
bot/src/roles/tempRoleSweeper.js
Normal file
48
bot/src/roles/tempRoleSweeper.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Once-a-minute sweep for expired temp_roles: removes the Discord role (best
|
||||||
|
// effort — the member/guild/role may already be gone) then deletes the row
|
||||||
|
// regardless, so a stale row can never block future re-grants of the same
|
||||||
|
// role to the same member.
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const tempRoles = require('../model/tempRoles')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('temproles')
|
||||||
|
|
||||||
|
let client = null
|
||||||
|
let task = null
|
||||||
|
|
||||||
|
async function sweep() {
|
||||||
|
try {
|
||||||
|
const expired = await tempRoles.listExpired()
|
||||||
|
for (const row of expired) {
|
||||||
|
try {
|
||||||
|
const guild = await client.guilds.fetch(row.guild_id)
|
||||||
|
const member = await guild.members.fetch(row.user_id).catch(() => null)
|
||||||
|
if (member) await member.roles.remove(row.role_id).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to remove expired temp role', { message: err.message, roleId: row.role_id, userId: row.user_id })
|
||||||
|
} finally {
|
||||||
|
await tempRoles.removeById(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('temp role sweep failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(discordClient) {
|
||||||
|
client = discordClient
|
||||||
|
task = cron.schedule('* * * * *', sweep)
|
||||||
|
log.info('temp role sweeper started')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (task) {
|
||||||
|
task.stop()
|
||||||
|
task = null
|
||||||
|
}
|
||||||
|
client = null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop }
|
||||||
83
bot/src/scheduler/scheduler.js
Normal file
83
bot/src/scheduler/scheduler.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Recurring + one-off scheduled channel messages. Recurring rows are each
|
||||||
|
// registered as their own node-cron task; one-off rows are picked up by a
|
||||||
|
// once-a-minute sweep that checks for anything due and marks it sent so it
|
||||||
|
// never reposts. Needs a live discord.js Client to actually send — wired up
|
||||||
|
// by discordManager.js (start() once the client is ready, stop() alongside
|
||||||
|
// client teardown).
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const scheduledMessages = require('../model/scheduledMessages')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('scheduler')
|
||||||
|
|
||||||
|
let discordClient = null
|
||||||
|
const recurringTasks = new Map() // id -> node-cron ScheduledTask
|
||||||
|
let sweepTask = null
|
||||||
|
|
||||||
|
async function sendToChannel(channelId, content) {
|
||||||
|
try {
|
||||||
|
const channel = await discordClient.channels.fetch(channelId)
|
||||||
|
if (!channel || !channel.isTextBased()) {
|
||||||
|
log.warn('scheduled message skipped — channel missing or not text-based', { channelId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await channel.send({ content })
|
||||||
|
log.info('sent scheduled message', { channelId })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to send scheduled message', { channelId, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecurring() {
|
||||||
|
for (const task of recurringTasks.values()) task.stop()
|
||||||
|
recurringTasks.clear()
|
||||||
|
|
||||||
|
const rows = await scheduledMessages.listEnabledRecurring()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!cron.validate(row.cron_expression)) {
|
||||||
|
log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content))
|
||||||
|
recurringTasks.set(row.id, task)
|
||||||
|
}
|
||||||
|
log.info('loaded recurring scheduled messages', { count: recurringTasks.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweepDueOneOff() {
|
||||||
|
try {
|
||||||
|
const due = await scheduledMessages.listDueOneOff()
|
||||||
|
for (const row of due) {
|
||||||
|
await sendToChannel(row.channel_id, row.content)
|
||||||
|
await scheduledMessages.markSent(row.id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('one-off sweep failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(client) {
|
||||||
|
discordClient = client
|
||||||
|
await loadRecurring()
|
||||||
|
sweepTask = cron.schedule('* * * * *', sweepDueOneOff)
|
||||||
|
log.info('scheduler started')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by /schedule after any add/remove so changes apply without a restart.
|
||||||
|
async function refresh() {
|
||||||
|
if (!discordClient) return
|
||||||
|
await loadRecurring()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
for (const task of recurringTasks.values()) task.stop()
|
||||||
|
recurringTasks.clear()
|
||||||
|
if (sweepTask) {
|
||||||
|
sweepTask.stop()
|
||||||
|
sweepTask = null
|
||||||
|
}
|
||||||
|
discordClient = null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, refresh }
|
||||||
52
bot/src/server.js
Normal file
52
bot/src/server.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
|
const app = require('./app')
|
||||||
|
const bootstrap = require('./bootstrap')
|
||||||
|
const createLogger = require('./utils/logger')
|
||||||
|
const discordManager = require('./discord/discordManager')
|
||||||
|
const pkg = require('../package.json')
|
||||||
|
|
||||||
|
const log = createLogger('server')
|
||||||
|
const PORT = Number(process.env.PORT) || 4100
|
||||||
|
const HOST = '0.0.0.0'
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
log.info(`starting UOMysticmoon bot v${pkg.version}`, {
|
||||||
|
node: process.version,
|
||||||
|
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = app.listen(PORT, HOST, () => {
|
||||||
|
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await bootstrap()
|
||||||
|
|
||||||
|
setupShutdown(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupShutdown(server) {
|
||||||
|
let closing = false
|
||||||
|
const shutdown = async (signal) => {
|
||||||
|
if (closing) return
|
||||||
|
closing = true
|
||||||
|
log.warn(`${signal} received — shutting down gracefully`)
|
||||||
|
server.close(() => log.info('internal API closed'))
|
||||||
|
await discordManager.stop()
|
||||||
|
await createLogger.close()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||||
|
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
log.error('uncaughtException', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((err) => {
|
||||||
|
log.error('failed to start bot', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
42
bot/src/site/siteApiClient.js
Normal file
42
bot/src/site/siteApiClient.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Read-only client for the main site's PUBLIC API (no shared secret — this is
|
||||||
|
// the same unauthenticated data any visitor's browser can fetch). Used by
|
||||||
|
// /wiki (search) and /announce (re-post an existing news item). Distinct from
|
||||||
|
// botInternalClient.js, which is the shared-secret-gated server<->bot channel.
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('site-api')
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public').replace(/\/+$/, '')
|
||||||
|
const TIMEOUT_MS = 5000
|
||||||
|
|
||||||
|
async function call(path) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, { signal: controller.signal })
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
// Public content routes 503 with this shape while the site is in
|
||||||
|
// maintenance mode (see server/src/middleware/siteMode.js) — surface it
|
||||||
|
// distinctly so commands can show a clear message instead of a generic error.
|
||||||
|
if (res.status === 503 && data?.mode === 'maintenance') {
|
||||||
|
return { ok: false, maintenance: true, message: data.message }
|
||||||
|
}
|
||||||
|
if (!res.ok) return { ok: false, error: `site responded ${res.status}` }
|
||||||
|
return { ok: true, data }
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('site API call failed', { path, message: err.message })
|
||||||
|
return { ok: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNewsPost(idOrSlug) {
|
||||||
|
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchWiki(query) {
|
||||||
|
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getNewsPost, searchWiki }
|
||||||
16
bot/src/utils/duration.js
Normal file
16
bot/src/utils/duration.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds.
|
||||||
|
// Returns null for anything unparseable. Discord's own timeout API caps at 28
|
||||||
|
// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input.
|
||||||
|
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
||||||
|
|
||||||
|
const MAX_TIMEOUT_MS = 28 * 86_400_000
|
||||||
|
|
||||||
|
function parseDuration(input) {
|
||||||
|
if (!input) return null
|
||||||
|
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim())
|
||||||
|
if (!match) return null
|
||||||
|
const [, amount, unit] = match
|
||||||
|
return Number(amount) * UNIT_MS[unit.toLowerCase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseDuration, MAX_TIMEOUT_MS }
|
||||||
97
bot/src/utils/logger.js
Normal file
97
bot/src/utils/logger.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Dual-transport logger: writes to the console AND to a log file.
|
||||||
|
// Levels: error | warn | info | debug.
|
||||||
|
// LOG_LEVEL console verbosity (default info)
|
||||||
|
// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk)
|
||||||
|
// LOG_TO_FILE enable file logging (default true)
|
||||||
|
// LOG_DIR log directory (default <bot>/logs)
|
||||||
|
// LOG_FILE log file name (default bot.log)
|
||||||
|
//
|
||||||
|
// Copied from server/src/utils/logger.js rather than shared — the bot is an
|
||||||
|
// independently deployable process with its own package.json/Dockerfile.
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
|
||||||
|
|
||||||
|
const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
|
||||||
|
const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug
|
||||||
|
|
||||||
|
// Color only on an interactive TTY — never in files or Docker logs.
|
||||||
|
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null
|
||||||
|
const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' }
|
||||||
|
const RESET = '\x1b[0m'
|
||||||
|
|
||||||
|
// ── File transport ────────────────────────────────────────────────────
|
||||||
|
const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false'
|
||||||
|
let fileStream = null
|
||||||
|
let logFilePath = null
|
||||||
|
|
||||||
|
if (fileEnabled) {
|
||||||
|
try {
|
||||||
|
const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs')
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
logFilePath = path.join(dir, process.env.LOG_FILE || 'bot.log')
|
||||||
|
fileStream = fs.createWriteStream(logFilePath, { flags: 'a' })
|
||||||
|
fileStream.on('error', (err) => {
|
||||||
|
process.stderr.write(`[logger] file logging disabled: ${err.message}\n`)
|
||||||
|
fileStream = null
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`[logger] could not open log file: ${err.message}\n`)
|
||||||
|
fileStream = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(meta) {
|
||||||
|
if (meta == null) return ''
|
||||||
|
if (typeof meta === 'string') return meta
|
||||||
|
if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack })
|
||||||
|
try {
|
||||||
|
return JSON.stringify(meta)
|
||||||
|
} catch {
|
||||||
|
return String(meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(level, tag, msg, meta) {
|
||||||
|
const levelNum = LEVELS[level]
|
||||||
|
if (levelNum === undefined) return
|
||||||
|
|
||||||
|
const ts = new Date().toISOString()
|
||||||
|
const lvl = level.toUpperCase().padEnd(5)
|
||||||
|
const label = tag ? ` [${tag}]` : ''
|
||||||
|
const metaStr = meta === undefined ? '' : ` ${fmt(meta)}`
|
||||||
|
const plain = `${ts} ${lvl}${label} ${msg}${metaStr}`
|
||||||
|
|
||||||
|
// Console transport
|
||||||
|
if (levelNum <= consoleThreshold) {
|
||||||
|
const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain
|
||||||
|
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout
|
||||||
|
stream.write(`${line}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File transport (plain text, no color)
|
||||||
|
if (fileStream && levelNum <= fileThreshold) {
|
||||||
|
fileStream.write(`${plain}\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogger(tag) {
|
||||||
|
return {
|
||||||
|
error: (msg, meta) => emit('error', tag, msg, meta),
|
||||||
|
warn: (msg, meta) => emit('warn', tag, msg, meta),
|
||||||
|
info: (msg, meta) => emit('info', tag, msg, meta),
|
||||||
|
debug: (msg, meta) => emit('debug', tag, msg, meta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush and close the file stream (called on graceful shutdown).
|
||||||
|
createLogger.close = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (fileStream) fileStream.end(resolve)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
createLogger.emit = emit
|
||||||
|
createLogger.logFilePath = logFilePath
|
||||||
|
module.exports = createLogger
|
||||||
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
"diff": "^5.2.2",
|
"diff": "^5.2.2",
|
||||||
@@ -1483,6 +1484,19 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@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": {
|
"node_modules/@tiptap/extension-text-style": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
"diff": "^5.2.2",
|
"diff": "^5.2.2",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
|||||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||||
import RequireAuth from './components/RequireAuth.jsx'
|
import RequireAuth from './components/RequireAuth.jsx'
|
||||||
|
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||||
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
import Portal from './routes/public/Portal.jsx'
|
||||||
@@ -14,19 +16,41 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
|||||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||||
import About from './routes/public/About.jsx'
|
import About from './routes/public/About.jsx'
|
||||||
import Status from './routes/public/Status.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 Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
||||||
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
||||||
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
||||||
import PostsAdmin from './routes/admin/views/PostsAdmin.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 WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.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 UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
|
|
||||||
|
// 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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -50,10 +74,19 @@ export default function App() {
|
|||||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
<Route path="/site/about" element={<About />} />
|
<Route path="/site/about" element={<About />} />
|
||||||
<Route path="/site/status" element={<Status />} />
|
<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" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<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>
|
</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 */}
|
{/* Admin */}
|
||||||
<Route path="/admin/login" element={<AdminLogin />} />
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -66,14 +99,50 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="posts" element={<PostsAdmin />} />
|
<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="wiki" element={<WikiAdmin />} />
|
||||||
<Route path="hero" element={<HeroEditor />} />
|
<Route path="hero" element={<HeroEditor />} />
|
||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
|
<Route
|
||||||
|
path="moderation"
|
||||||
|
element={
|
||||||
|
<RoleGate roles={['admin', 'moderator']}>
|
||||||
|
<Outlet />
|
||||||
|
</RoleGate>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Moderation />} />
|
||||||
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||||
|
</Route>
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<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="users" element={<UsersAdmin />} />
|
||||||
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Player portal */}
|
||||||
|
<Route path="/account/login" element={<PlayerLogin />} />
|
||||||
|
<Route path="/account/register" element={<PlayerRegister />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<RequirePlayer>
|
||||||
|
<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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
|
|||||||
@@ -41,8 +41,21 @@ function safeParse(text) {
|
|||||||
export const api = {
|
export const api = {
|
||||||
// ----- auth -----
|
// ----- auth -----
|
||||||
me: () => req('/auth/me'),
|
me: () => req('/auth/me'),
|
||||||
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
|
// `extra` carries the honeypot field (and any future login fields).
|
||||||
|
login: (username, password, extra = {}) =>
|
||||||
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
|
// Public self-registration (player accounts). `extra` carries the honeypot +
|
||||||
|
// optional email. Returns { user } and sets the session cookie on success.
|
||||||
|
register: (username, password, extra = {}) =>
|
||||||
|
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
|
loginTotp: (challenge, code) =>
|
||||||
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||||
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||||
|
// the callback, so only the code is sent). Returns { user, returnTo }.
|
||||||
|
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||||
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||||
|
authProviders: () => req('/auth/providers'),
|
||||||
|
|
||||||
// ----- public -----
|
// ----- public -----
|
||||||
publicSettings: () => req('/public/settings'),
|
publicSettings: () => req('/public/settings'),
|
||||||
@@ -60,8 +73,34 @@ export const api = {
|
|||||||
wikiCategories: () => req('/public/wiki/categories'),
|
wikiCategories: () => req('/public/wiki/categories'),
|
||||||
wikiTags: () => req('/public/wiki/tags'),
|
wikiTags: () => req('/public/wiki/tags'),
|
||||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
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 }),
|
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 -----
|
||||||
admin: {
|
admin: {
|
||||||
dashboard: () => req('/admin/dashboard'),
|
dashboard: () => req('/admin/dashboard'),
|
||||||
@@ -73,6 +112,10 @@ export const api = {
|
|||||||
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
publishPost: (id, published) =>
|
publishPost: (id, published) =>
|
||||||
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { 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) => {
|
uploadImage: (file) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('image', file)
|
fd.append('image', file)
|
||||||
@@ -84,6 +127,15 @@ export const api = {
|
|||||||
fd.append('image', file)
|
fd.append('image', file)
|
||||||
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
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}`),
|
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
||||||
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
||||||
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
||||||
@@ -104,10 +156,127 @@ export const api = {
|
|||||||
getSettings: () => req('/admin/settings'),
|
getSettings: () => req('/admin/settings'),
|
||||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||||
|
botActivity: () => req('/admin/bot-activity'),
|
||||||
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||||
listUsers: () => req('/admin/users'),
|
listUsers: () => req('/admin/users'),
|
||||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
|
modRecent: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||||
|
modMembers: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modFilterHits: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modSpamHits: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||||
|
modUserActions: (discordId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||||
|
addModNote: (discordId, data) =>
|
||||||
|
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
||||||
|
|
||||||
|
// ----- account security (self-service 2FA) -----
|
||||||
|
getAccount: () => req('/admin/account'),
|
||||||
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||||
|
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||||
|
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||||
|
|
||||||
|
// ----- linked SSO identities (self-service) -----
|
||||||
|
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 }),
|
||||||
|
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
||||||
|
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- 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') -----
|
||||||
|
// Mirrors the admin account methods but self-scoped under /player. The change
|
||||||
|
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
||||||
|
player: {
|
||||||
|
getAccount: () => req('/player/account'),
|
||||||
|
changeUsername: (username) =>
|
||||||
|
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
||||||
|
changePassword: (newPassword, currentPassword) =>
|
||||||
|
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||||
|
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
||||||
|
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
||||||
|
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 { Link } from 'react-router-dom'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
||||||
|
|
||||||
@@ -34,7 +35,19 @@ function TextBlock({ props }) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||||
{(props.lines || []).map((line, i) => {
|
{(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 (
|
return (
|
||||||
<Tag key={i} style={lineStyle(line)}>
|
<Tag key={i} style={lineStyle(line)}>
|
||||||
{line.text}
|
{line.text}
|
||||||
@@ -107,12 +120,15 @@ function content(element) {
|
|||||||
case 'buttons':
|
case 'buttons':
|
||||||
return <Buttons props={element.props || {}} />
|
return <Buttons props={element.props || {}} />
|
||||||
case 'moon': {
|
case 'moon': {
|
||||||
const size = element.props?.size || 96
|
const props = element.props || {}
|
||||||
const glow = element.props?.glow ?? 0.45
|
const size = props.size || 96
|
||||||
|
const glow = props.glow ?? 0.45
|
||||||
|
// Image source is configurable; old layouts with no src fall back to the
|
||||||
|
// default hero moon so they render exactly as before. Size/glow unchanged.
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={MOON_IMAGE}
|
src={props.src || MOON_IMAGE}
|
||||||
alt=""
|
alt={props.alt || ''}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
|
|||||||
37
client/src/components/ProviderIcon.jsx
Normal file
37
client/src/components/ProviderIcon.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Inline SVG brand icons for SSO providers. No binary assets — these scale
|
||||||
|
// crisply at any size and keep their own brand colors. `icon` matches the
|
||||||
|
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
|
||||||
|
// Anything unknown falls back to a neutral key glyph in the current text color.
|
||||||
|
|
||||||
|
function GoogleMark({ size }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||||
|
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||||
|
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||||
|
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscordMark({ size }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="#5865F2" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericMark({ size }) {
|
||||||
|
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">
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProviderIcon({ icon, size = 18 }) {
|
||||||
|
if (icon === 'google') return <GoogleMark size={size} />
|
||||||
|
if (icon === 'discord') return <DiscordMark size={size} />
|
||||||
|
return <GenericMark size={size} />
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom'
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
// Gate for /admin/* — redirects to the login screen when not authenticated.
|
// Gate for /admin/* — redirects to the login screen when not authenticated, and
|
||||||
|
// bounces a signed-in player to their own portal (the admin API 403s them anyway;
|
||||||
|
// this keeps the UI honest and mirrors RequirePlayer).
|
||||||
export default function RequireAuth({ children }) {
|
export default function RequireAuth({ children }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -16,5 +18,8 @@ export default function RequireAuth({ children }) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/admin/login" state={{ from: location }} replace />
|
return <Navigate to="/admin/login" state={{ from: location }} replace />
|
||||||
}
|
}
|
||||||
|
if (user.role === 'player') {
|
||||||
|
return <Navigate to="/account" replace />
|
||||||
|
}
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
23
client/src/components/RequirePlayer.jsx
Normal file
23
client/src/components/RequirePlayer.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Gate for the /account player portal. Redirects to the player login when there
|
||||||
|
// is no session, or when the signed-in user is not a player (staff manage their
|
||||||
|
// own account under /admin/account). Server-side requireRole('player') is the
|
||||||
|
// real enforcement; this just keeps the UI honest.
|
||||||
|
export default function RequirePlayer({ children }) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
|
||||||
|
<span className="spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!user || user.role !== 'player') {
|
||||||
|
return <Navigate to="/account/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
|
|||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import { api } from '../api/client.js'
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
// Toolbar button.
|
// Toolbar button.
|
||||||
@@ -25,6 +26,22 @@ function escapeHtml(s) {
|
|||||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
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:
|
// Toolbar variants:
|
||||||
// 'full' — every control, incl. the internal wiki-page link picker (wiki use).
|
// '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).
|
// '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] } }),
|
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||||||
Link.configure({ openOnClick: false, autolink: true }),
|
Link.configure({ openOnClick: false, autolink: true }),
|
||||||
Image.configure({ inline: false }),
|
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 || '',
|
content: value || '',
|
||||||
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||||
@@ -131,6 +153,16 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
|
|||||||
—
|
—
|
||||||
</Btn>
|
</Btn>
|
||||||
<span className="rte-sep" />
|
<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 title="Link" active={editor.isActive('link')} onClick={setLink}>
|
||||||
🔗
|
🔗
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|||||||
11
client/src/components/RoleGate.jsx
Normal file
11
client/src/components/RoleGate.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Client-side role gate for admin sub-sections. Real enforcement is server-side
|
||||||
|
// (requireRole); this just keeps the UI honest — a user without one of `roles`
|
||||||
|
// is redirected rather than shown a page that will only 403 on every call.
|
||||||
|
export default function RoleGate({ roles, children, redirect = '/admin' }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (user && !roles.includes(user.role)) return <Navigate to={redirect} replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link, NavLink } from 'react-router-dom'
|
||||||
import MoonDot from './MoonDot.jsx'
|
import MoonDot from './MoonDot.jsx'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
const NAV = {
|
// One consistent top nav for the whole public site. Every page gets the same
|
||||||
website: [
|
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||||
{ label: 'News', to: '/site/news' },
|
const NAV = [
|
||||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'News', to: '/site/news' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Wiki', to: '/wiki' },
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||||
],
|
{ label: 'Wiki', to: '/wiki' },
|
||||||
wiki: [
|
{ label: 'Shard', to: '/site/shard' },
|
||||||
{ label: 'Website', to: '/site' },
|
{ label: 'About', to: '/site/about' },
|
||||||
{ label: 'New Player Guide', to: '/wiki/new-player-guide' },
|
]
|
||||||
{ label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
|
|
||||||
{ label: 'Systems', to: '/wiki/systems' },
|
const linkStyle = ({ isActive }) => ({
|
||||||
{ label: 'Rules', to: '/wiki/rules' },
|
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@@ -34,38 +45,31 @@ export default function SiteHeader({ section = 'website' }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="shell"
|
className="shell"
|
||||||
style={{
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, padding: '14px 0', flexWrap: 'wrap' }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 20,
|
|
||||||
padding: '14px 0',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="display"
|
className="display"
|
||||||
style={{
|
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
color: 'var(--accent-bright)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MoonDot />
|
<MoonDot />
|
||||||
UOMysticmoon
|
UOMysticmoon
|
||||||
</Link>
|
</Link>
|
||||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
{links.map((l) => (
|
{NAV.map((l) => (
|
||||||
<Link key={l.to + l.label} to={l.to} className="pill">
|
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||||
{l.label}
|
{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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,12 +22,37 @@ export function AuthProvider({ children }) {
|
|||||||
refresh()
|
refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const login = useCallback(async (username, password) => {
|
// Step 1. Returns { user } on success, or { totpRequired, challenge } when the
|
||||||
const data = await api.login(username, password)
|
// account has 2FA on (caller then calls loginTotp). `extra` carries honeypot.
|
||||||
|
const login = useCallback(async (username, password, extra) => {
|
||||||
|
const data = await api.login(username, password, extra)
|
||||||
|
if (data.user) setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Public self-registration (player). Creates the account, sets the session
|
||||||
|
// cookie, and returns { user }. `extra` carries the honeypot + optional email.
|
||||||
|
const register = useCallback(async (username, password, extra) => {
|
||||||
|
const data = await api.register(username, password, extra)
|
||||||
|
if (data.user) setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
||||||
|
const loginTotp = useCallback(async (challenge, code) => {
|
||||||
|
const data = await api.loginTotp(challenge, code)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
return data.user
|
return data.user
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
|
||||||
|
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
|
||||||
|
const ssoLoginTotp = useCallback(async (code) => {
|
||||||
|
const data = await api.ssoLoginTotp(code)
|
||||||
|
setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
@@ -37,7 +62,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: '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: '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: '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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,6 +84,25 @@ export function defaultLayout(teaser) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'default-quick-links',
|
||||||
|
type: 'buttons',
|
||||||
|
x: 50,
|
||||||
|
y: 85,
|
||||||
|
z: 3,
|
||||||
|
anchor: 'center',
|
||||||
|
props: {
|
||||||
|
align: 'center',
|
||||||
|
gap: 10,
|
||||||
|
items: [
|
||||||
|
{ label: 'News', to: '/site/news', variant: 'ghost' },
|
||||||
|
{ label: 'Screenshots', to: '/site/screenshots', variant: 'ghost' },
|
||||||
|
{ label: 'Five on Friday', to: '/site/five-on-friday', variant: 'ghost' },
|
||||||
|
{ label: 'Monthly Newsletter', to: '/site/newsletter', variant: 'ghost' },
|
||||||
|
{ label: 'About', to: '/site/about', variant: 'ghost' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,27 +1,108 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// 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 = [
|
const NAV = [
|
||||||
{ to: '/admin', label: 'Dashboard', end: true },
|
{
|
||||||
{ to: '/admin/posts', label: 'Posts' },
|
items: [
|
||||||
{ to: '/admin/wiki', label: 'Wiki' },
|
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
],
|
||||||
{ to: '/admin/settings', label: 'Settings' },
|
},
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
{
|
||||||
{ to: '/admin/users', label: 'Users' },
|
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 = {
|
const TITLES = {
|
||||||
'/admin': 'Dashboard',
|
'/admin': 'Dashboard',
|
||||||
'/admin/posts': 'Posts',
|
'/admin/posts': 'Posts',
|
||||||
|
'/admin/pages': 'Pages',
|
||||||
'/admin/wiki': 'Wiki Pages',
|
'/admin/wiki': 'Wiki Pages',
|
||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
|
'/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/users': 'Users',
|
||||||
|
'/admin/account': 'Account Security',
|
||||||
}
|
}
|
||||||
|
|
||||||
const navBtnBase = {
|
const navBtnBase = {
|
||||||
@@ -31,7 +112,9 @@ const navBtnBase = {
|
|||||||
fontFamily: 'var(--sans)',
|
fontFamily: 'var(--sans)',
|
||||||
fontSize: '0.92rem',
|
fontSize: '0.92rem',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
display: 'block',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
transition: 'background .15s,color .15s',
|
transition: 'background .15s,color .15s',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +123,64 @@ export default function AdminLayout() {
|
|||||||
const { mode } = useSite()
|
const { mode } = useSite()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = TITLES[location.pathname] || 'Admin'
|
const title =
|
||||||
|
TITLES[location.pathname] ||
|
||||||
|
(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.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
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 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.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isModerator) return
|
||||||
|
const p = location.pathname
|
||||||
|
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
|
||||||
|
navigate('/admin/moderation', { replace: true })
|
||||||
|
}
|
||||||
|
}, [isModerator, location.pathname, navigate])
|
||||||
|
|
||||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const meta = document.createElement('meta')
|
const meta = document.createElement('meta')
|
||||||
@@ -85,22 +221,71 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||||
{NAV.map((n) => (
|
{navGroups.map((group, gi) => {
|
||||||
<NavLink
|
const links = group.items.map((n) => (
|
||||||
key={n.to}
|
<NavLink
|
||||||
to={n.to}
|
key={n.to}
|
||||||
end={n.end}
|
to={n.to}
|
||||||
style={({ isActive }) => ({
|
end={n.end}
|
||||||
...navBtnBase,
|
className="admin-nav-link"
|
||||||
background: isActive ? 'var(--blue)' : 'transparent',
|
style={({ isActive }) => ({
|
||||||
color: isActive ? 'var(--ink)' : 'var(--muted)',
|
...navBtnBase,
|
||||||
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
background: isActive ? 'var(--blue)' : 'transparent',
|
||||||
})}
|
color: isActive ? 'var(--ink)' : 'var(--muted)',
|
||||||
>
|
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||||
{n.label}
|
})}
|
||||||
</NavLink>
|
>
|
||||||
))}
|
{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>
|
</nav>
|
||||||
|
|
||||||
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
||||||
|
|||||||
@@ -1,40 +1,145 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||||
|
const SSO_ERRORS = {
|
||||||
|
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
||||||
|
disabled: 'This account is not active. Contact an administrator.',
|
||||||
|
denied: 'Sign-in was cancelled.',
|
||||||
|
unavailable: 'That sign-in method is not available right now.',
|
||||||
|
bad_state: 'Your sign-in session expired. Please try again.',
|
||||||
|
error: 'Could not complete sign-in. Please try again.',
|
||||||
|
}
|
||||||
|
|
||||||
const BG =
|
const BG =
|
||||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||||
|
|
||||||
|
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
||||||
|
// skip) so real users never fill it but naive scripted bots do. Name must match
|
||||||
|
// the server's HONEYPOT_FIELD ('company').
|
||||||
|
const honeypotStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-9999px',
|
||||||
|
top: 'auto',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const { user, login } = useAuth()
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/admin'
|
const dest = location.state?.from?.pathname || '/admin'
|
||||||
|
// A player who signs in here belongs in the player portal, not the admin shell
|
||||||
|
// (the admin API 403s them anyway). Staff go to their intended admin dest.
|
||||||
|
const destFor = (u) => (u && u.role === 'player' ? '/account' : dest)
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
// Already signed in → go straight to the panel.
|
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
|
||||||
|
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
|
||||||
|
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||||
|
const [challenge, setChallenge] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [ssoTotp, setSsoTotp] = useState(false)
|
||||||
|
|
||||||
|
// SSO providers to offer (empty if none configured) + any error the callback
|
||||||
|
// bounced us back with (?sso_error=...).
|
||||||
|
const [providers, setProviders] = useState([])
|
||||||
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||||
|
|
||||||
|
// Already signed in → go straight to the right home for the role.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) navigate(dest, { replace: true })
|
if (user) navigate(destFor(user), { replace: true })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, dest, navigate])
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
||||||
|
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
|
||||||
|
// before it will issue a session. Jump straight to the code step.
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(location.search).get('sso_totp')) {
|
||||||
|
setStage('totp')
|
||||||
|
setSsoTotp(true)
|
||||||
|
}
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
|
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
||||||
|
// still works with password login and simply shows no provider buttons.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api
|
||||||
|
.authProviders()
|
||||||
|
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => active && setProviders([]))
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Full-page redirect into the provider's OAuth flow, preserving the intended
|
||||||
|
// destination so the callback can return the user there.
|
||||||
|
function startSso(provider) {
|
||||||
|
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
|
||||||
|
window.location.assign(provider.loginUrl + q)
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(e) {
|
async function onSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await login(username, password)
|
const data = await login(username, password, { company })
|
||||||
navigate(dest, { replace: true })
|
if (data.totpRequired) {
|
||||||
|
setChallenge(data.challenge)
|
||||||
|
setStage('totp')
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(destFor(data.user), { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSubmitTotp(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (ssoTotp) {
|
||||||
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
|
navigate(returnTo || '/admin', { replace: true })
|
||||||
|
} else {
|
||||||
|
const u = await loginTotp(challenge, code)
|
||||||
|
navigate(destFor(u), { replace: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
|
setError(
|
||||||
|
expired
|
||||||
|
? 'Your verification session expired. Please sign in again.'
|
||||||
|
: 'Invalid verification code.',
|
||||||
|
)
|
||||||
|
setBusy(false)
|
||||||
|
if (expired) {
|
||||||
|
setStage('creds')
|
||||||
|
setSsoTotp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
@@ -63,7 +168,7 @@ export default function AdminLogin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--line)',
|
border: '1px solid var(--line)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -73,31 +178,67 @@ export default function AdminLogin() {
|
|||||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
{stage === 'creds' ? (
|
||||||
<span className="field-label">Username</span>
|
<>
|
||||||
<input
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
type="text"
|
<span className="field-label">Username</span>
|
||||||
autoComplete="username"
|
<input
|
||||||
autoFocus
|
type="text"
|
||||||
value={username}
|
autoComplete="username"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
autoFocus
|
||||||
className="input"
|
value={username}
|
||||||
/>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
</label>
|
className="input"
|
||||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
/>
|
||||||
<span className="field-label">Password</span>
|
</label>
|
||||||
<input
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
type="password"
|
<span className="field-label">Password</span>
|
||||||
autoComplete="current-password"
|
<input
|
||||||
value={password}
|
type="password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
autoComplete="current-password"
|
||||||
className="input"
|
value={password}
|
||||||
/>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</label>
|
className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
{error && (
|
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
|
||||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
<div style={honeypotStyle} aria-hidden="true">
|
||||||
{error}
|
<label>
|
||||||
|
Company
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="company"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={company}
|
||||||
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Authentication code</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
placeholder="6-digit code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
|
Enter the code from your authenticator app.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || (stage === 'creds' && ssoError)) && (
|
||||||
|
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||||
|
{error || ssoError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -107,8 +248,47 @@ export default function AdminLogin() {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||||
>
|
>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
||||||
|
{stage === 'creds' && providers.length > 0 && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => startSso(p)}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 11,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Continue with {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
Protected area — not indexed. Sessions expire after 1 day.
|
Protected area — not indexed. Sessions expire after 1 day.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
308
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
308
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||||
|
// the provider's OAuth flow (/auth/sso/:id/link) and returns here with ?linked
|
||||||
|
// or ?link_error. Only providers that are enabled + valid can be linked.
|
||||||
|
function LinkedAccounts() {
|
||||||
|
const [linked, setLinked] = useState(null)
|
||||||
|
const [available, setAvailable] = useState([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const banner = (() => {
|
||||||
|
const q = new URLSearchParams(window.location.search)
|
||||||
|
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||||
|
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||||
|
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [ids, avail] = await Promise.all([
|
||||||
|
api.admin.linkedIdentities(),
|
||||||
|
api.authProviders().catch(() => []),
|
||||||
|
])
|
||||||
|
setLinked(ids)
|
||||||
|
setAvailable(Array.isArray(avail) ? avail : [])
|
||||||
|
} catch {
|
||||||
|
setError('Could not load linked accounts.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||||
|
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||||
|
|
||||||
|
async function unlink(provider) {
|
||||||
|
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||||
|
try {
|
||||||
|
await api.admin.unlinkIdentity(provider)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not unlink.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!linked) return null
|
||||||
|
|
||||||
|
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||||
|
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Linked accounts
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||||
|
to an account it is linked to — linking here is what grants that access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{banner && (
|
||||||
|
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>
|
||||||
|
{banner.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linked.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||||
|
{linked.map((i) => (
|
||||||
|
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||||
|
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||||
|
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkable.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||||
|
{linkable.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link`)}
|
||||||
|
className="btn"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Link {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linked.length === 0 && linkable.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem' }}>
|
||||||
|
No SSO providers are enabled. Configure them under <strong>Authentication</strong>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-service account security: enable / disable optional TOTP two-factor.
|
||||||
|
export default function AccountAdmin() {
|
||||||
|
const [account, setAccount] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Enrollment state.
|
||||||
|
const [setup, setSetup] = useState(null) // { qr, otpauthUrl }
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setAccount(await api.admin.getAccount())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your account.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
|
||||||
|
async function beginSetup() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setSetup(await api.admin.totpSetup())
|
||||||
|
setCode('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not start setup.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEnable() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.totpEnable(code.trim())
|
||||||
|
setSetup(null)
|
||||||
|
setCode('')
|
||||||
|
setMsg('Two-factor authentication is now enabled.')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not enable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.totpDisable(code.trim())
|
||||||
|
setCode('')
|
||||||
|
setMsg('Two-factor authentication has been disabled.')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not disable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = account?.totp_enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 560 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Two-factor authentication
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Add a time-based one-time code (TOTP) from an authenticator app as a second step at login.
|
||||||
|
Optional, and only affects your own account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
color: enabled ? '#7fd0a4' : 'var(--muted)',
|
||||||
|
marginBottom: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: enabled ? '#7fd0a4' : 'var(--dim)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{enabled ? 'Enabled' : 'Not enabled'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enable flow */}
|
||||||
|
{!enabled && !setup && (
|
||||||
|
<div>
|
||||||
|
<button onClick={beginSetup} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Preparing…' : 'Set up two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!enabled && setup && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.
|
||||||
|
</p>
|
||||||
|
<img
|
||||||
|
src={setup.qr}
|
||||||
|
alt="TOTP QR code"
|
||||||
|
width={180}
|
||||||
|
height={180}
|
||||||
|
style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }}
|
||||||
|
/>
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={confirmEnable} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Enabling…' : 'Confirm & enable'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSetup(null)} disabled={busy} className="pill">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disable flow */}
|
||||||
|
{enabled && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
Enter a current code from your authenticator to turn two-factor off.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||||
|
{busy ? 'Disabling…' : 'Disable two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||||
|
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
<LinkedAccounts />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user