Compare commits
59 Commits
2306545574
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5103b74a9d | |||
| c91fd128bf | |||
| 01a559792c | |||
| e50fab241f | |||
| 779a304173 | |||
| c6c0c257dd | |||
| 8771a1cf6c | |||
| 8da658f223 | |||
| bda031566a | |||
| b61a4d6721 | |||
| 1e1a3d67c3 | |||
| 26094459ae | |||
| bfa1db58c4 | |||
| 7c769ea8fd | |||
| f3d084e046 | |||
| a4ef9d676d | |||
| 2801ec8f4d | |||
| 353cce9f26 | |||
| 7b98f1a778 | |||
| 61d6bfaca2 | |||
| 6b1396dd2f | |||
| f30ea66fce | |||
| cd56af3f12 | |||
| f3450686e0 | |||
| a3407ae654 | |||
| 620781b7bc | |||
| f6611231c4 | |||
| a6fd5659c4 | |||
| 068844bfd9 | |||
| 565a7d2c20 | |||
| 3fcc64ab96 | |||
| 8fd0d82580 | |||
| 812b895507 | |||
| 00ad16858a | |||
| 493843241e | |||
| bd53a0b8a4 | |||
| 0e11e28cca | |||
| f7c98b8ba3 | |||
| 8ad892725f | |||
| 1a61cd1638 | |||
| 0dc5af0d8b | |||
| 9b74999610 | |||
| 49b70ee04d | |||
| 1079b3fc05 | |||
| cbe54fcc91 | |||
| 9f9bcc6f6e | |||
| ebfae765d9 | |||
| c075ab981c | |||
| bcdba4ce0a | |||
| e08c0c9736 | |||
| 5fe7032567 | |||
| 4151f7d44e | |||
| 4f1a4902e8 | |||
| 14dfc122ba | |||
| 514bc9d23c | |||
| 60ebacff2c | |||
| 8d5bdc0d6e | |||
| c991a07c8a | |||
| 4c13706958 |
11
.env.example
11
.env.example
@@ -117,7 +117,10 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||
# 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
|
||||
# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
|
||||
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
|
||||
# if you deliberately run an older sidecar.
|
||||
UOLINK_PROTOCOL=3
|
||||
|
||||
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
|
||||
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
||||
@@ -131,6 +134,12 @@ UOLINK_PROTOCOL=1
|
||||
# register endpoints on a different host than NTFY_BASE_URL.
|
||||
# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token;
|
||||
# set one only to require auth on backend→ntfy publishes.
|
||||
# NTFY_HOST_PORT Host port the ntfy container publishes :80 on (default
|
||||
# 2586). The public reverse proxy forwards the notification
|
||||
# subdomain to host:NTFY_HOST_PORT — required because the
|
||||
# proxy lives outside the compose network and cannot reach
|
||||
# ntfy any other way. Change only on a host-port conflict.
|
||||
NTFY_BASE_URL=https://ntfy.example.com
|
||||
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
||||
# NTFY_PUBLISH_TOKEN=
|
||||
# NTFY_HOST_PORT=2586
|
||||
|
||||
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||
|
||||
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||
|
||||
Deterministic ordering: directories before files, each group sorted
|
||||
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def build(paths):
|
||||
root = {}
|
||||
for p in paths:
|
||||
p = p.strip().replace("\\", "/")
|
||||
if not p:
|
||||
continue
|
||||
node = root
|
||||
for part in p.split("/"):
|
||||
node = node.setdefault(part, {})
|
||||
return root
|
||||
|
||||
|
||||
def render(node, prefix, lines):
|
||||
entries = list(node.items())
|
||||
# directories (non-empty children dict) before files, then case-insensitive name
|
||||
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||
for i, (name, child) in enumerate(entries):
|
||||
last = i == len(entries) - 1
|
||||
branch = "└── " if last else "├── "
|
||||
suffix = "/" if child else ""
|
||||
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||
if child:
|
||||
render(child, prefix + (" " if last else "│ "), lines)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||
except AttributeError:
|
||||
pass
|
||||
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
tree = build(sys.stdin.read().splitlines())
|
||||
lines = [f"{root_label}/"]
|
||||
render(tree, "", lines)
|
||||
sys.stdout.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -40,6 +40,13 @@ jobs:
|
||||
run: npm ci --prefix server
|
||||
- name: Run server tests
|
||||
run: npm test --prefix server
|
||||
- name: Check the route manifest is current
|
||||
# The URL surface is frozen while the routers are carved up by capability
|
||||
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
|
||||
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that
|
||||
# really does change one has to commit the new manifest, putting it in front
|
||||
# of a reviewer instead of letting it pass silently.
|
||||
run: npm run routes:manifest --prefix server -- --check
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
name: sync-project-tree
|
||||
|
||||
# Keeps this repo's file-layout snapshot (docs/website/PROJECT_TREE.md in the
|
||||
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||
# request against the docs repo. It never writes to the docs repo's `main`
|
||||
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sync-project-tree
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
DOCS_REPO: RunicGateway/docs
|
||||
SELF_REPO: RunicGateway/website
|
||||
DOCS_PATH: website/PROJECT_TREE.md
|
||||
TREE_TITLE: Website
|
||||
ROOT_LABEL: website
|
||||
PR_BRANCH: chore/sync-website-tree
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out this repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure python3 is available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||
|
||||
- name: Render PROJECT_TREE.md from tracked files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _sync
|
||||
{
|
||||
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||
printf 'git-ignored paths are excluded).\n\n'
|
||||
printf '```text\n'
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||
printf '```\n'
|
||||
} > _sync/PROJECT_TREE.md
|
||||
echo "----- generated ${DOCS_PATH} -----"
|
||||
cat _sync/PROJECT_TREE.md
|
||||
|
||||
- name: Open or update the docs PR if the tree changed
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||
# strip line breaks before they land in a URL or Authorization header.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||
|
||||
git clone --depth 1 "${REMOTE}" docs_repo
|
||||
cd docs_repo
|
||||
git config user.name "runic-docs-bot"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
|
||||
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||
git add "${DOCS_PATH}"
|
||||
if git diff --cached --quiet; then
|
||||
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||
git checkout -B "${PR_BRANCH}"
|
||||
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||
|
||||
# Open a PR only if one isn't already open for this branch (a force-push
|
||||
# to an existing open PR's head updates it in place).
|
||||
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||
"${API}/pulls?state=open&limit=50" \
|
||||
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||
if [ "${OPEN}" = "0" ]; then
|
||||
curl -sSf -X POST "${API}/pulls" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg head "${PR_BRANCH}" \
|
||||
--arg base "main" \
|
||||
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||
>/dev/null
|
||||
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||
else
|
||||
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||
fi
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -21,6 +21,24 @@ uploads/
|
||||
server/logs/
|
||||
logs/
|
||||
|
||||
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
|
||||
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
|
||||
# not ours to redistribute. The images live under server/uploads/atlas/, already
|
||||
# ignored above; this is the slug -> file-name map pointing at them.
|
||||
# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json.
|
||||
server/db/data/spawnAtlas.art.json
|
||||
|
||||
# Operator-supplied cliloc table. UO's localization strings are EA's, extracted
|
||||
# from the operator's own client and converted once (docs/website/CLILOCS.md);
|
||||
# the repo ships no string table, for the same reason it ships no artwork and no
|
||||
# map snapshot. This covers the conventional in-repo location — the supported
|
||||
# arrangement is a path OUTSIDE the repo, set from Admin → Shard.
|
||||
server/db/data/cliloc*
|
||||
server/db/data/clilocs.*
|
||||
# The build output of tools/cliloc-export (a throwaway helper, not a package).
|
||||
server/tools/cliloc-export/bin/
|
||||
server/tools/cliloc-export/obj/
|
||||
|
||||
# reference material (extracted from the provided archives)
|
||||
_reference/
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ If you add or change an API route, regenerate the Swagger spec
|
||||
(`cd server && npm run swagger`) and commit the updated
|
||||
`server/swagger/swagger-output.json`.
|
||||
|
||||
The URL surface is also frozen by a generated manifest. If your change adds,
|
||||
removes or renames a route, regenerate it (`cd server && npm run routes:manifest`)
|
||||
and commit `server/routes.manifest.json` + `server/routes.guards.json` — CI fails
|
||||
otherwise. A non-empty diff in `routes.manifest.json` means you changed the API
|
||||
contract, so call it out in the PR description; a pure refactor must produce none.
|
||||
|
||||
## Branch & PR workflow
|
||||
|
||||
1. Fork or branch from `main`. Use a descriptive branch name
|
||||
|
||||
126
README.md
126
README.md
@@ -25,6 +25,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
||||
|
||||
## Contents
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Project structure](#project-structure)
|
||||
- [Prerequisites](#prerequisites)
|
||||
@@ -44,6 +45,102 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
How the pieces fit together — the React SPA and native app talk to one Express backend
|
||||
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
|
||||
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
%% ---------- Clients ----------
|
||||
subgraph clients["Clients"]
|
||||
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
|
||||
mobile["Native mobile app<br/>(bearer tokens)"]
|
||||
end
|
||||
|
||||
idp["SSO providers<br/>Google · Discord · custom OIDC"]
|
||||
discord["Discord"]
|
||||
|
||||
%% ---------- Website (one repo) ----------
|
||||
subgraph website["website/ — Node app (one repo)"]
|
||||
direction TB
|
||||
|
||||
subgraph backend["server/ — Express backend"]
|
||||
direction TB
|
||||
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
|
||||
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
|
||||
ctrl["Controllers"]
|
||||
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
||||
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
||||
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
||||
|
||||
subgraph shardutil["Shard integration (utils/)"]
|
||||
ingest["shardIngest.js<br/>WS ingest dispatcher"]
|
||||
restcli["uoLinkClient.js<br/>REST client (never throws)"]
|
||||
end
|
||||
|
||||
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
||||
end
|
||||
|
||||
bot["bot/<br/>Discord bot"]
|
||||
end
|
||||
|
||||
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
|
||||
|
||||
%% ---------- Shard side ----------
|
||||
subgraph shardside["Game shard (never internet-facing)"]
|
||||
direction TB
|
||||
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
|
||||
servuo["ServUO shard<br/>(C# plugin)"]
|
||||
end
|
||||
|
||||
%% ---------- Edges ----------
|
||||
browser <-->|"same-origin JSON + SSE (cookie)"| mw
|
||||
mobile -->|"REST (bearer access/refresh)"| mw
|
||||
browser -.->|"OAuth redirect + PKCE"| idp
|
||||
auth -.->|"token exchange"| idp
|
||||
|
||||
mw --> router --> ctrl
|
||||
ctrl --> auth
|
||||
ctrl --> model
|
||||
ctrl --> restcli
|
||||
ctrl --> sse
|
||||
auth --> model
|
||||
model <--> db
|
||||
auth -. reads/writes secrets .-> secret
|
||||
restcli -. reads config/token .-> secret
|
||||
ingest --> model
|
||||
ingest --> sse
|
||||
sse -->|"live events"| browser
|
||||
bot -->|"messages"| discord
|
||||
bot <--> db
|
||||
|
||||
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
|
||||
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
|
||||
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
|
||||
|
||||
%% ---------- Styling ----------
|
||||
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
|
||||
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
|
||||
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
|
||||
class idp,discord ext;
|
||||
class db store;
|
||||
class sidecar,servuo bridge;
|
||||
```
|
||||
|
||||
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
|
||||
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
|
||||
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
|
||||
All three surfaces produce the *same* session via the session layer.
|
||||
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
|
||||
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
|
||||
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
|
||||
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
|
||||
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Tech |
|
||||
@@ -279,6 +376,35 @@ 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).
|
||||
|
||||
### The route manifest (frozen URL surface)
|
||||
|
||||
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
|
||||
Express listeners actually expose. It is **not** documentation — it is the machine-checkable freeze of
|
||||
the URL surface, so that carving the router files up by business capability
|
||||
(`docs/website/API_V2_PLAN.md`) can be proved to move no URL instead of merely claiming it.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run routes:manifest # → routes.manifest.json + routes.guards.json
|
||||
npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
|
||||
```
|
||||
|
||||
The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
|
||||
sits on the line *after* `router.get(`, which defeats greps) and keeps only
|
||||
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
|
||||
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
|
||||
not depend on whether the client has been built.
|
||||
|
||||
Two generated files, two very different meanings:
|
||||
|
||||
| File | Meaning of a diff |
|
||||
|---|---|
|
||||
| `routes.manifest.json` | **Contract change.** A URL moved. Justify it in the PR description; never let one ride along in a "mechanical" refactor. |
|
||||
| `routes.guards.json` | **Review aid.** Per route: handler count + the *named* middleware on its mount chain. Names are a hint only — `requireRole(...)` returns an anonymous arrow and cannot be seen — but a vanished `requireAuth` is unambiguous. |
|
||||
|
||||
Unlike the Swagger spec, the manifest is annotation-free: `swagger-output.json` documents intent (only
|
||||
annotated routes appear), the manifest records reality.
|
||||
|
||||
---
|
||||
|
||||
## Shard integration (uo-link)
|
||||
|
||||
@@ -22,6 +22,12 @@ import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||
import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
import Atlas from './routes/public/Atlas.jsx'
|
||||
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||
import Market from './routes/public/Market.jsx'
|
||||
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -40,6 +46,8 @@ 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 ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
|
||||
import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
|
||||
import ShardOps from './routes/admin/views/ShardOps.jsx'
|
||||
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||
@@ -97,6 +105,12 @@ export default function App() {
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/site/rules" element={<Rules />} />
|
||||
<Route path="/site/atlas" element={<Atlas />} />
|
||||
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
||||
<Route path="/site/market" element={<Market />} />
|
||||
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
@@ -142,6 +156,8 @@ export default function App() {
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
|
||||
<Route
|
||||
path="shard-ops"
|
||||
element={
|
||||
|
||||
@@ -56,8 +56,12 @@ export const api = {
|
||||
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||
acceptInvite: (token, username, password, extra = {}) =>
|
||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||
loginTotp: (challenge, code) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||
// Second factor for web login. `extra` carries the optional recoveryCode (an
|
||||
// alternative to code) and the trustDevice/deviceName opt-in. On success the
|
||||
// response may include { trustLimitReached, devices } when trust was requested
|
||||
// but the device cap is reached.
|
||||
loginTotp: (challenge, code, extra = {}) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
|
||||
// Self-service password reset (public, token-gated). forgot always resolves the
|
||||
// same way whether or not the email exists (no enumeration); getPasswordReset
|
||||
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
|
||||
@@ -67,8 +71,10 @@ export const api = {
|
||||
resetPassword: (token, password) =>
|
||||
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
|
||||
// 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 } }),
|
||||
// the callback, so only the code is sent). `extra` carries the trustDevice/
|
||||
// deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
|
||||
// { trustLimitReached, devices } when trust was asked for but the cap is reached.
|
||||
ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
|
||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||
authProviders: () => req('/auth/providers'),
|
||||
@@ -76,6 +82,20 @@ export const api = {
|
||||
// List the active ones and revoke a single device by its session id.
|
||||
mySessions: () => req('/auth/me/sessions'),
|
||||
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
|
||||
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
|
||||
// mySessions, which are live mobile login sessions).
|
||||
myTrustedDevices: () => req('/auth/me/trusted-devices'),
|
||||
trustThisDevice: (deviceName) =>
|
||||
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
|
||||
revokeTrustedDevice: (id) =>
|
||||
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
@@ -127,6 +147,75 @@ export const api = {
|
||||
},
|
||||
presence: () => req('/public/shard/presence'),
|
||||
houses: () => req('/public/shard/houses'),
|
||||
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
|
||||
// shard has never published one — a real answer, not an error.
|
||||
ruleset: () => req('/public/shard/ruleset'),
|
||||
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
||||
// `board` 404s for a system the shard has never published.
|
||||
points: () => req('/public/shard/points'),
|
||||
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
||||
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
||||
// the page debounces its search box rather than firing per keystroke.
|
||||
market: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
||||
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
||||
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
||||
if (opts.map) qs.set('map', opts.map)
|
||||
if (opts.region) qs.set('region', opts.region)
|
||||
if (opts.sort) qs.set('sort', opts.sort)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return req(`/public/shard/market${withQs(qs.toString())}`)
|
||||
},
|
||||
marketMeta: () => req('/public/shard/market/meta'),
|
||||
marketVendor: (serial, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
||||
},
|
||||
// Which shard surfaces this caller may reach, plus the audience rung they
|
||||
// resolved to. Drives nav so we never render a link that would 403.
|
||||
features: () => req('/public/shard/features'),
|
||||
},
|
||||
|
||||
// ----- spawn atlas (Protocol 3.0 Part C) -----
|
||||
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
||||
// not under /shard, because nothing here depends on the sidecar and the pages
|
||||
// stay populated while the shard is offline.
|
||||
atlas: {
|
||||
creatures: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
||||
},
|
||||
creature: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.points) qs.set('points', opts.points)
|
||||
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
||||
},
|
||||
regions: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
||||
},
|
||||
landmarks: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||
},
|
||||
// The CONFIGURED altar roster, not the live board — see shard.champs() for
|
||||
// "which spawn is on level 3 right now".
|
||||
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
||||
meta: () => req('/public/atlas/meta'),
|
||||
},
|
||||
// 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
|
||||
@@ -199,6 +288,13 @@ export const api = {
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
// A user's trusted devices + MFA reset (admin only).
|
||||
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
|
||||
revokeUserTrustedDevice: (id, deviceId) =>
|
||||
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
|
||||
revokeAllUserTrustedDevices: (id) =>
|
||||
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
|
||||
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
|
||||
// Email invites.
|
||||
listInvites: () => req('/admin/invites'),
|
||||
createInvite: (email, role, sendEmail = true) =>
|
||||
@@ -319,6 +415,25 @@ export const api = {
|
||||
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' }),
|
||||
// Per-feature shard visibility: who may see which shard surface, and which
|
||||
// sensitive fields within it. Admin only — it decides what ANONYMOUS
|
||||
// visitors get. acct/webId are admin-only always and the API rejects any
|
||||
// attempt to configure them.
|
||||
getShardVisibility: () => req('/admin/shard/visibility'),
|
||||
saveShardVisibility: (features) =>
|
||||
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
||||
|
||||
// ----- spawn atlas operation (admin only) -----
|
||||
// The atlas re-derives itself from the ServUO tree on every boot; these are
|
||||
// for applying a map change without a restart, and for the approve/reject
|
||||
// decision on a refresh that would remove a facet.
|
||||
atlas: {
|
||||
status: () => req('/admin/shard/atlas'),
|
||||
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
||||
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
||||
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
||||
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
|
||||
@@ -10,24 +10,89 @@ import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
// What to call an equipped item.
|
||||
//
|
||||
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
|
||||
// to show nothing but the layer and `id 12345`. The server now resolves the
|
||||
// cliloc against its own table and attaches `clilocName` (see
|
||||
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
|
||||
// and the layer fallback below is exactly what the sheet did before.
|
||||
//
|
||||
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
|
||||
// should not be relabelled "hatchet" — and the server applies the same
|
||||
// precedence, so this only re-states it for a profile that arrived with both.
|
||||
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
|
||||
|
||||
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||
// literal string. Without a cliloc table on the site we can only show literals, so
|
||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||
// de-duped list of human-readable title chips.
|
||||
// literal string.
|
||||
//
|
||||
// `rewardResolved` is the server's parallel array with the numeric entries turned
|
||||
// into words (null where the cliloc table had nothing, or is not configured at
|
||||
// all). Prefer it, and keep the literal-only path as the fallback for a profile
|
||||
// served before the cliloc table existed — a numeric entry with no resolution is
|
||||
// still skipped rather than shown as a raw number.
|
||||
function displayTitles(titles) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const raw = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
|
||||
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
|
||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||
// Prefer the selected reward title; fall back to the first literal one.
|
||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||
// Prefer the selected reward title; fall back to the first one that resolved.
|
||||
// The `??` matters: a selected title whose cliloc did not resolve must fall
|
||||
// through to the fallback rather than suppress the chip entirely.
|
||||
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
|
||||
if (candidate) out.push(String(candidate))
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
|
||||
// the character actually holds a score in. Systems at zero are omitted by the
|
||||
// shard, so an empty list means "this character has earned nothing anywhere",
|
||||
// which is a normal state for a new character and renders as nothing at all.
|
||||
//
|
||||
// `nameString` may be null when the system's name is a cliloc; fall back to
|
||||
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
|
||||
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
|
||||
// "unranked" are different, so the chip only appears when it was actually sent.
|
||||
const humanisePoints = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
function PointsRow({ entry }) {
|
||||
const label = entry.nameString || humanisePoints(entry.system)
|
||||
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
|
||||
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
|
||||
{label}
|
||||
{Number.isFinite(entry.rank) && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{(entry.points ?? 0).toLocaleString()}
|
||||
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
|
||||
be a fraction of, and a full-width bar would imply completion. */}
|
||||
{max > 0 && (
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
@@ -75,6 +140,11 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
.filter((s) => (s.value || s.base || 0) > 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||
const equipment = char.equipment || []
|
||||
// Best standing first, so the character's strongest loyalty leads. Guarded for
|
||||
// an older shard plugin that sends no `points` block at all.
|
||||
const points = (Array.isArray(char.points) ? char.points : [])
|
||||
.filter((p) => p && (p.points || 0) > 0)
|
||||
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
@@ -173,17 +243,37 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||
{points.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Loyalty & points <span className="dim">({points.length})</span>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{points.map((p) => (
|
||||
<PointsRow key={p.system} entry={p} />
|
||||
))}
|
||||
</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) => (
|
||||
{equipment.map((it) => {
|
||||
const label = itemName(it)
|
||||
const layer = it.layer || 'Item'
|
||||
// The layer only earns its own line once the headline is a real
|
||||
// name; when it IS the headline, repeating it is just noise.
|
||||
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
|
||||
return (
|
||||
<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 className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</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%' }}>
|
||||
@@ -193,7 +283,8 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,15 @@ import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||
|
||||
// One consistent top nav for the whole public site. Every page gets the same
|
||||
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||
//
|
||||
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
|
||||
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
||||
// viewer can't reach them, so we never render a link that would 403. The gate
|
||||
// itself is server-side; this is only about not advertising a dead end.
|
||||
const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
@@ -12,11 +18,15 @@ const NAV = [
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard' },
|
||||
{ label: 'Champions', to: '/site/champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds' },
|
||||
{ label: 'Governors', to: '/site/governors' },
|
||||
{ label: 'Houses', to: '/site/houses' },
|
||||
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||
{ label: 'Market', to: '/site/market', feature: 'market' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
@@ -29,6 +39,8 @@ const linkStyle = ({ isActive }) => ({
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
let account
|
||||
@@ -60,7 +72,7 @@ export default function SiteHeader() {
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{NAV.map((l) => (
|
||||
{nav.map((l) => (
|
||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
|
||||
62
client/src/components/security/RecoveryCodesDisplay.jsx
Normal file
62
client/src/components/security/RecoveryCodesDisplay.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// Renders a freshly generated batch of recovery codes ONCE, with copy + download.
|
||||
// The backend never returns these again, so the copy stresses saving them now.
|
||||
export default function RecoveryCodesDisplay({ codes, onDone }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const text = (codes || []).join('\n')
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
/* clipboard blocked — the codes are visible to copy manually */
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([`${text}\n`], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'recovery-codes.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 18, marginTop: 8 }}>
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Save these recovery codes somewhere safe. Each can be used <strong>once</strong> to sign in if you
|
||||
lose your authenticator. <strong>They will not be shown again.</strong>
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||||
gap: 8,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.95rem',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{(codes || []).map((c) => (
|
||||
<div key={c} style={{ padding: '8px 10px', border: '1px solid var(--line-soft)', borderRadius: 6, letterSpacing: '0.06em', textAlign: 'center', color: 'var(--head)' }}>
|
||||
{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={copy} className="pill">{copied ? 'Copied!' : 'Copy'}</button>
|
||||
<button onClick={download} className="pill">Download</button>
|
||||
{onDone && (
|
||||
<button onClick={onDone} className="btn btn-primary btn-sq" style={{ marginLeft: 'auto' }}>
|
||||
I’ve saved them
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
client/src/components/security/RecoveryCodesPanel.jsx
Normal file
86
client/src/components/security/RecoveryCodesPanel.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
import RecoveryCodesDisplay from './RecoveryCodesDisplay.jsx'
|
||||
|
||||
// Self-service recovery (backup) codes. Shows how many remain and lets the user
|
||||
// regenerate a fresh set (password step-up). Shown only when 2FA is enabled.
|
||||
// `hasPassword` decides whether the current-password field is required — an
|
||||
// SSO-only account with no password may regenerate while authenticated.
|
||||
export default function RecoveryCodesPanel({ hasPassword = true }) {
|
||||
const [remaining, setRemaining] = useState(null)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [codes, setCodes] = useState(null) // freshly generated batch, shown once
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { remaining: n } = await api.recoveryCodesStatus()
|
||||
setRemaining(n)
|
||||
} catch {
|
||||
/* non-fatal — the panel still offers regeneration */
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function regenerate() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const { recoveryCodes } = await api.generateRecoveryCodes(hasPassword ? currentPassword : undefined)
|
||||
setCodes(recoveryCodes)
|
||||
setCurrentPassword('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not generate recovery codes.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
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)' }}>
|
||||
Recovery codes
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
|
||||
codes you still have.
|
||||
</p>
|
||||
|
||||
{remaining != null && !codes && (
|
||||
<p className="sans" style={{ color: remaining > 0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
|
||||
{remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{codes ? (
|
||||
<RecoveryCodesDisplay codes={codes} onDone={() => setCodes(null)} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 }}>
|
||||
{hasPassword && (
|
||||
<label style={{ display: 'block', maxWidth: 260 }}>
|
||||
<span className="field-label">Current password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div>
|
||||
<button onClick={regenerate} disabled={busy || (hasPassword && !currentPassword)} className="btn btn-sq">
|
||||
{busy ? 'Generating…' : 'Generate new codes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
client/src/components/security/TrustLimitModal.jsx
Normal file
124
client/src/components/security/TrustLimitModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Shown when a user tries to trust a device but is already at the trusted-device
|
||||
// cap. Styled like the TOTP entry flow (centered card on a dim overlay). The user
|
||||
// MUST revoke at least one existing device before they can continue — there is no
|
||||
// silent pruning — or they can cancel and leave the device untrusted.
|
||||
//
|
||||
// Props:
|
||||
// devices — the existing trusted devices (from the 409 / trustLimitReached payload)
|
||||
// onTrusted — called after the current device is successfully trusted (post-revoke)
|
||||
// onCancel — called when the user backs out without trusting this device
|
||||
export default function TrustLimitModal({ devices: initialDevices, onTrusted, onCancel }) {
|
||||
const [devices, setDevices] = useState(initialDevices || [])
|
||||
const [revokedAny, setRevokedAny] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function revoke(id) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.revokeTrustedDevice(id)
|
||||
setDevices((list) => list.filter((d) => d.id !== id))
|
||||
setRevokedAny(true)
|
||||
} catch {
|
||||
setError('Could not revoke that device. Please try again.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function trustNow() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.trustThisDevice()
|
||||
onTrusted?.()
|
||||
} catch (err) {
|
||||
// Still at the cap somehow (a race) — surface it and let them revoke more.
|
||||
if (err.status === 409 && err.body?.devices) {
|
||||
setDevices(err.body.devices)
|
||||
setError('Still at the limit — revoke another device.')
|
||||
} else {
|
||||
setError('Could not trust this device. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlay} role="dialog" aria-modal="true" aria-label="Trusted-device limit reached">
|
||||
<div style={card}>
|
||||
<h2 className="display" style={{ margin: '0 0 8px', fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
Trusted-device limit reached
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '0 0 16px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
You can trust up to {Math.max(devices.length, 1)} devices. Revoke one below to make room, then
|
||||
continue — or cancel to leave this device untrusted.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16, maxHeight: 240, overflowY: 'auto' }}>
|
||||
{devices.map((d) => (
|
||||
<div key={d.id} style={row}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{d.deviceName || d.platform || 'Device'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.userAgent || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{devices.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>All devices revoked. You can trust this one now.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.84rem' }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={trustNow} disabled={busy || !revokedAny} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Working…' : 'Trust this device'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="pill">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
zIndex: 1000,
|
||||
}
|
||||
const card = {
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
background: 'var(--panel, #1a1a1f)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
}
|
||||
const row = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 8,
|
||||
}
|
||||
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal file
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
import TrustLimitModal from './TrustLimitModal.jsx'
|
||||
|
||||
// Self-service list of the devices allowed to skip the TOTP step at login (MFA
|
||||
// "Trust this device"). Uses the role-agnostic /auth/me/trusted-devices surface, so
|
||||
// the same panel serves players and staff. Shown only when 2FA is enabled — trust
|
||||
// is meaningless without a second factor to skip.
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—'
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export default function TrustedDevicesPanel() {
|
||||
const [devices, setDevices] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [capModal, setCapModal] = useState(null) // { devices } when the cap is hit
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setDevices(await api.myTrustedDevices())
|
||||
} catch {
|
||||
setError('Could not load your trusted devices.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function trustThis() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.trustThisDevice()
|
||||
setMsg('This device is now trusted.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (err.status === 409 && err.body?.error === 'trusted_device_limit') {
|
||||
setCapModal({ devices: err.body.devices || [] })
|
||||
} else {
|
||||
setError('Could not trust this device.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id) {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.revokeTrustedDevice(id)
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not revoke that device.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAll() {
|
||||
if (!window.confirm('Untrust every device? Each will require the full two-factor step at the next login.')) return
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.revokeAllTrustedDevices()
|
||||
setMsg('All devices untrusted.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not untrust devices.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!devices) return null
|
||||
|
||||
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)' }}>
|
||||
Trusted devices
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Devices you’ve trusted skip the authenticator step at login (your password is still required).
|
||||
Revoke any you don’t recognize.
|
||||
</p>
|
||||
|
||||
{devices.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{devices.map((d) => (
|
||||
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.userAgent || '—'} · last used {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>No trusted devices yet.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={trustThis} disabled={busy} className="btn btn-sq">Trust this device</button>
|
||||
{devices.length > 0 && (
|
||||
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Untrust all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
|
||||
{capModal && (
|
||||
<TrustLimitModal
|
||||
devices={capModal.devices}
|
||||
onTrusted={() => { setCapModal(null); setMsg('This device is now trusted.'); load() }}
|
||||
onCancel={() => setCapModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,17 +38,22 @@ export function AuthProvider({ children }) {
|
||||
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)
|
||||
// Step 2 for TOTP users: exchange the challenge + a second factor (TOTP code or a
|
||||
// recovery code) for a real session. `extra` carries recoveryCode + the
|
||||
// trustDevice/deviceName opt-in. Returns the full payload ({ user,
|
||||
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
|
||||
const loginTotp = useCallback(async (challenge, code, extra) => {
|
||||
const data = await api.loginTotp(challenge, code, extra)
|
||||
setUser(data.user)
|
||||
return data.user
|
||||
return data
|
||||
}, [])
|
||||
|
||||
// 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)
|
||||
// an httpOnly cookie, so only the code is sent. `extra` carries the trustDevice/
|
||||
// deviceName opt-in. Returns the full payload ({ user, returnTo,
|
||||
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
|
||||
const ssoLoginTotp = useCallback(async (code, extra) => {
|
||||
const data = await api.ssoLoginTotp(code, extra)
|
||||
setUser(data.user)
|
||||
return data
|
||||
}, [])
|
||||
|
||||
58
client/src/lib/useShardFeatures.js
Normal file
58
client/src/lib/useShardFeatures.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Which shard surfaces the current viewer may reach, from
|
||||
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
|
||||
// Visibility), so the nav can't be a static list any more.
|
||||
//
|
||||
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
|
||||
// and an out-of-rung one 403s whether or not the link is rendered. So while the
|
||||
// answer is still in flight we return `null` and callers show their default set
|
||||
// — better a link that briefly 403s than a nav that flickers in on every load.
|
||||
//
|
||||
// Cached module-level: the answer is per-viewer but stable for a session, and
|
||||
// every consumer would otherwise refetch it on mount.
|
||||
let cached = null
|
||||
let inFlight = null
|
||||
|
||||
export function resetShardFeatures() {
|
||||
cached = null
|
||||
inFlight = null
|
||||
}
|
||||
|
||||
export function useShardFeatures() {
|
||||
const [features, setFeatures] = useState(cached)
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return undefined
|
||||
let alive = true
|
||||
inFlight =
|
||||
inFlight ||
|
||||
api.shard
|
||||
.features()
|
||||
.then((data) => {
|
||||
cached = { level: data.level, set: new Set(data.features || []) }
|
||||
return cached
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed lookup must not blank the nav — fall back to "show
|
||||
// everything" and let the server do the gating.
|
||||
cached = null
|
||||
inFlight = null
|
||||
return null
|
||||
})
|
||||
inFlight.then((result) => {
|
||||
if (alive) setFeatures(result)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
// Convenience: true when `name` is visible, or when we don't know yet.
|
||||
export function canSee(features, name) {
|
||||
return !features || features.set.has(name)
|
||||
}
|
||||
@@ -78,6 +78,8 @@ const NAV = [
|
||||
{ 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/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
@@ -106,6 +108,8 @@ const TITLES = {
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
'/admin/discord-bot': 'Discord Bot',
|
||||
'/admin/shard': 'Shard (uo-link)',
|
||||
'/admin/shard-visibility': 'Shard Visibility',
|
||||
'/admin/shard-atlas': 'Spawn Atlas',
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
@@ -52,6 +53,9 @@ export default function AdminLogin() {
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [ssoTotp, setSsoTotp] = useState(false)
|
||||
const [trustDevice, setTrustDevice] = useState(false)
|
||||
const [useRecovery, setUseRecovery] = useState(false)
|
||||
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } when the cap is hit
|
||||
|
||||
// SSO providers to offer (empty if none configured) + any error the callback
|
||||
// bounced us back with (?sso_error=...).
|
||||
@@ -119,19 +123,34 @@ export default function AdminLogin() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (ssoTotp) {
|
||||
const { returnTo } = await ssoLoginTotp(code)
|
||||
navigate(returnTo || '/admin', { replace: true })
|
||||
// Trust works on the SSO second factor exactly as it does on the password
|
||||
// one — the IdP already proved the first factor.
|
||||
const data = await ssoLoginTotp(code.trim(), { trustDevice })
|
||||
const to = data.returnTo || '/admin'
|
||||
if (data.trustLimitReached) {
|
||||
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(to, { replace: true })
|
||||
} else {
|
||||
const u = await loginTotp(challenge, code)
|
||||
navigate(destFor(u), { replace: true })
|
||||
const entered = code.trim()
|
||||
const data = await loginTotp(challenge, useRecovery ? '' : entered, {
|
||||
recoveryCode: useRecovery ? entered : undefined,
|
||||
trustDevice,
|
||||
})
|
||||
const to = destFor(data.user)
|
||||
if (data.trustLimitReached) {
|
||||
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(to, { 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.',
|
||||
)
|
||||
const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
|
||||
setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
|
||||
setBusy(false)
|
||||
if (expired) {
|
||||
setStage('creds')
|
||||
@@ -223,22 +242,42 @@ export default function AdminLogin() {
|
||||
</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>
|
||||
<>
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder={useRecovery ? 'xxxxx-xxxxx' : '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' }}>
|
||||
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
|
||||
</span>
|
||||
</label>
|
||||
{/* Offered on the SSO second factor too — the trust is on the device,
|
||||
not on how the first factor was proved. */}
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
Trust this device for 30 days (skip the code next time)
|
||||
</label>
|
||||
{/* Recovery codes remain password-login only: the SSO second step
|
||||
verifies an authenticator code against the staged challenge. */}
|
||||
{!ssoTotp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
|
||||
className="sans"
|
||||
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
|
||||
>
|
||||
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(error || (stage === 'creds' && ssoError)) && (
|
||||
@@ -304,6 +343,14 @@ export default function AdminLogin() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{trustLimit && (
|
||||
<TrustLimitModal
|
||||
devices={trustLimit.devices}
|
||||
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
|
||||
onCancel={() => navigate(trustLimit.dest, { replace: true })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
@@ -127,6 +130,7 @@ export default function AccountAdmin() {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -164,9 +168,10 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpEnable(code.trim())
|
||||
const res = await api.admin.totpEnable(code.trim())
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
setNewCodes(res?.recoveryCodes || null)
|
||||
setMsg('Two-factor authentication is now enabled.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
@@ -302,6 +307,21 @@ export default function AccountAdmin() {
|
||||
{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>}
|
||||
|
||||
{/* One-time recovery codes shown right after enabling 2FA. */}
|
||||
{newCodes && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trusted devices + recovery-code management, only relevant with 2FA on. */}
|
||||
{enabled && (
|
||||
<>
|
||||
<TrustedDevicesPanel />
|
||||
<RecoveryCodesPanel hasPassword={account?.has_password !== false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<LinkedAccounts />
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -4,9 +4,15 @@ import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const { user } = useAuth()
|
||||
// PUT /admin/site-mode is adminOnly. The dashboard itself is staff-wide, so the
|
||||
// toggle needs its own gate — same rule the sidebar follows (AdminLayout: never
|
||||
// show a non-admin a control that would 403).
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
|
||||
@@ -15,6 +21,7 @@ export default function Dashboard() {
|
||||
[tick],
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [modeError, setModeError] = useState('')
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the dashboard." />
|
||||
@@ -32,12 +39,21 @@ export default function Dashboard() {
|
||||
{ value: dash.counts?.users ?? 0, label: 'Users' },
|
||||
]
|
||||
|
||||
// The rejection was previously unhandled: a refused toggle surfaced only as an
|
||||
// unhandled promise rejection in the console while the button silently reverted.
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
setModeError('')
|
||||
try {
|
||||
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
|
||||
await refreshSite()
|
||||
reload()
|
||||
} catch (err) {
|
||||
setModeError(
|
||||
err.status === 403
|
||||
? 'Only an administrator can change the site mode.'
|
||||
: 'Could not change the site mode. Try again.',
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -78,15 +94,22 @@ export default function Dashboard() {
|
||||
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
|
||||
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
|
||||
</div>
|
||||
{modeError && (
|
||||
<div className="sans" style={{ fontSize: '0.8rem', marginTop: 8, color: 'var(--danger, #d98b8b)' }}>
|
||||
{modeError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
className="sans"
|
||||
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
|
||||
>
|
||||
{modeLabel}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
className="sans"
|
||||
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
|
||||
>
|
||||
{modeLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function ShardAdmin() {
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [protocol, setProtocol] = useState(1)
|
||||
const [protocol, setProtocol] = useState(3)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
|
||||
if (!initializedRef.current) {
|
||||
setBaseUrl(c.baseUrl || '')
|
||||
setWsUrl(c.wsUrl || '')
|
||||
setProtocol(c.protocol || 1)
|
||||
setProtocol(c.protocol || 3)
|
||||
setEnabled(c.enabled)
|
||||
initializedRef.current = true
|
||||
}
|
||||
|
||||
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// ── Admin · Shard visibility ────────────────────────────────────────────────
|
||||
//
|
||||
// Who may see which shard surface, and which sensitive fields within it.
|
||||
// Admin-only, because this decides what ANONYMOUS visitors get.
|
||||
//
|
||||
// Two things the UI must communicate honestly, because they are not negotiable
|
||||
// server-side (see docs/link/v3.md §3.4):
|
||||
// • acct / webId are admin-only always and are not listed as editable fields.
|
||||
// • an event kind the server doesn't know about never reaches anyone below
|
||||
// admin, whatever is set here.
|
||||
//
|
||||
// Defaults reproduce the behavior the site had before this panel existed, so a
|
||||
// fresh install shows "everything as it was" rather than an empty form.
|
||||
|
||||
const RUNG_LABEL = {
|
||||
anonymous: 'Everyone',
|
||||
logged_in: 'Signed in',
|
||||
player: 'Linked players',
|
||||
staff: 'Staff',
|
||||
admin: 'Admins only',
|
||||
}
|
||||
|
||||
const RUNG_HINT = {
|
||||
anonymous: 'Visible to anyone, signed in or not.',
|
||||
logged_in: 'Any signed-in account, linked or not.',
|
||||
player: 'Accounts with a linked game account. Staff always qualify.',
|
||||
staff: 'Admins and moderators.',
|
||||
admin: 'Admins only.',
|
||||
}
|
||||
|
||||
const FEATURE_LABEL = {
|
||||
status: 'Shard status',
|
||||
activity: 'Activity feed',
|
||||
champs: 'Champion spawns',
|
||||
guilds: 'Guilds',
|
||||
governors: 'Town governors',
|
||||
houses: 'Houses / IDOC',
|
||||
presence: 'Players online',
|
||||
ruleset: 'Shard rules',
|
||||
atlas: 'Spawn atlas',
|
||||
leaderboards: 'Leaderboards',
|
||||
market: 'Marketplace',
|
||||
}
|
||||
|
||||
const FEATURE_HINT = {
|
||||
status: 'Connection state, online count, gold-supply series.',
|
||||
activity: 'Deaths, kills, skill gains, quests, logins.',
|
||||
champs: 'The live champion / mini-champ / sea-boss board.',
|
||||
guilds: 'Guild rosters, alliances and leaders.',
|
||||
governors: 'City Loyalty governors, elections and term history.',
|
||||
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
|
||||
presence: 'Population aggregate and the staff-online widget.',
|
||||
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
|
||||
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
|
||||
leaderboards: 'Point and loyalty standings across every points system.',
|
||||
market: 'The shard-wide player-vendor index.',
|
||||
}
|
||||
|
||||
const FIELD_LABEL = {
|
||||
owner: 'House owner',
|
||||
price: 'House price',
|
||||
location: 'In-game location (map + coordinates)',
|
||||
connect: 'Server connect address',
|
||||
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
|
||||
// projection matches literal JSON keys, so the rule cannot be spelled after the
|
||||
// field's meaning. The label is what carries the meaning to the admin.
|
||||
name: 'Character names on leaderboards',
|
||||
ownerName: 'Vendor owner name',
|
||||
// One rule, one key — `location` is a nested object on both the wire frame and
|
||||
// the stored read model precisely so that hiding it takes the facet, the
|
||||
// coordinates, the region and the house together.
|
||||
ownerSerial: 'Vendor owner character id',
|
||||
}
|
||||
|
||||
function RungSelect({ value, onChange, ladder, disabled }) {
|
||||
return (
|
||||
<select
|
||||
className="input"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ maxWidth: 200 }}
|
||||
>
|
||||
{ladder.map((rung) => (
|
||||
<option key={rung} value={rung}>
|
||||
{RUNG_LABEL[rung] || rung}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
|
||||
const fields = Object.entries(settings.fields || {})
|
||||
const changed =
|
||||
defaults &&
|
||||
(settings.enabled !== defaults.enabled ||
|
||||
settings.audience !== defaults.audience ||
|
||||
settings.stream !== defaults.stream ||
|
||||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
opacity: settings.enabled ? 1 : 0.62,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{FEATURE_LABEL[name] || name}
|
||||
{changed && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
|
||||
>
|
||||
changed
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||
{FEATURE_HINT[name]}
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
className="sans"
|
||||
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Who can see it</span>
|
||||
<RungSelect
|
||||
value={settings.audience}
|
||||
ladder={ladder}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(audience) => onPatch(name, { audience })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
|
||||
{RUNG_HINT[settings.audience]}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="sans"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.stream}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(e) => onPatch(name, { stream: e.target.checked })}
|
||||
/>
|
||||
Live updates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
|
||||
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Sensitive fields
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||
{fields.map(([field, rung]) => (
|
||||
<label key={field} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
|
||||
{FIELD_LABEL[field] || field}
|
||||
</span>
|
||||
<RungSelect
|
||||
value={rung}
|
||||
ladder={ladder}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(level) =>
|
||||
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardVisibility() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [defaults, setDefaults] = useState(null)
|
||||
const [ladder, setLadder] = useState([])
|
||||
const [lockedFields, setLockedFields] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.admin.getShardVisibility()
|
||||
setConfig(data.features)
|
||||
setDefaults(data.defaults)
|
||||
setLadder(data.ladder || [])
|
||||
setLockedFields(data.lockedFields || [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load visibility settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
function patch(name, changes) {
|
||||
setMsg('')
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev[name], ...changes }
|
||||
// `fieldRules` in the API is `fields` in the effective config.
|
||||
if (changes.fieldRules) {
|
||||
next.fields = changes.fieldRules
|
||||
delete next.fieldRules
|
||||
}
|
||||
return { ...prev, [name]: next }
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const body = {}
|
||||
for (const [name, s] of Object.entries(config)) {
|
||||
body[name] = {
|
||||
enabled: s.enabled,
|
||||
audience: s.audience,
|
||||
stream: s.stream,
|
||||
fieldRules: s.fields || {},
|
||||
}
|
||||
}
|
||||
const data = await api.admin.saveShardVisibility(body)
|
||||
setConfig(data.features)
|
||||
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
setMsg('')
|
||||
setConfig(structuredClone(defaults))
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !config) return <ErrorState message={error} onRetry={load} />
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Shard visibility
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Choose who can see each shard surface on the public site, and how much detail they get.
|
||||
Turning a feature off hides it entirely — its pages return “not found” rather than
|
||||
revealing that it exists. “Live updates” controls whether the feature streams changes in
|
||||
real time; the pages still work without it, they just refresh on load.
|
||||
</p>
|
||||
{lockedFields.length > 0 && (
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
|
||||
game account names and website user ids are never shown below admin, on any surface. They
|
||||
aren’t visible in game either, so publishing them would disclose something the shard
|
||||
itself doesn’t.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{Object.entries(config).map(([name, settings]) => (
|
||||
<FeatureRow
|
||||
key={name}
|
||||
name={name}
|
||||
settings={settings}
|
||||
defaults={defaults?.[name]}
|
||||
ladder={ladder}
|
||||
onPatch={patch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
|
||||
Restore defaults
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
|
||||
// this panel exists for the three things a restart cannot do:
|
||||
//
|
||||
// • point it at a different tree,
|
||||
// • apply a map change without restarting, and
|
||||
// • answer a refresh that was parsed but deliberately NOT applied because it
|
||||
// would remove a facet.
|
||||
//
|
||||
// That last one is the reason the panel is worth building. Losing a facet looks
|
||||
// exactly like a half-copied or mid-update tree, and boot cannot tell them
|
||||
// apart — so it stages the decision for a human instead of guessing. Until
|
||||
// someone decides here, the site keeps serving the atlas it already had.
|
||||
|
||||
// A refresh reports its outcome rather than throwing (the boot path must never
|
||||
// be stopped by a bad tree), so these are answers, not errors — the panel says
|
||||
// what happened in the shard's terms instead of showing a failure box.
|
||||
const OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
|
||||
unchanged: (r) =>
|
||||
r.reason === 'refresh previously rejected'
|
||||
? 'Unchanged — this exact tree was already reviewed and declined.'
|
||||
: 'Unchanged — the tree matches what is already loaded.',
|
||||
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
|
||||
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
|
||||
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
|
||||
rejected: () => 'Declined. It will not be offered again until the tree changes.',
|
||||
}
|
||||
|
||||
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
|
||||
|
||||
function Row({ label, children }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim">{label}</span>
|
||||
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingReview({ pending, busy, onApprove, onReject }) {
|
||||
const declined = pending.status === 'rejected'
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
{declined ? (
|
||||
<>
|
||||
This tree was reviewed and declined, so it is not offered again until the files change.
|
||||
Approving now applies it anyway.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
|
||||
</strong>
|
||||
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
|
||||
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
|
||||
not applied. Approving re-parses the tree as it is right now — if you have since fixed the
|
||||
mount, what lands is the corrected import.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
|
||||
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
|
||||
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
|
||||
Approve and import
|
||||
</button>
|
||||
{!declined && (
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
|
||||
Keep the current atlas
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SpawnAtlas() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [path, setPath] = useState('')
|
||||
const [force, setForce] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.admin.atlas.status()
|
||||
setStatus(data)
|
||||
setPath(data.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load atlas status.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Every mutating action shares this: run it, report what it said, then reload
|
||||
// status so the panel reflects the world rather than what we assumed happened.
|
||||
async function run(action, fn) {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
setMsg(describe(result))
|
||||
const fresh = await api.admin.atlas.status()
|
||||
setStatus(fresh)
|
||||
setPath(fresh.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || `Could not ${action}.`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function savePath() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const fresh = await api.admin.atlas.setPath(path.trim())
|
||||
setStatus(fresh)
|
||||
setPath(fresh.path || '')
|
||||
setMsg(
|
||||
fresh.path === ''
|
||||
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
|
||||
: fresh.treeReadable
|
||||
? 'Saved. The tree is readable — import when you are ready.'
|
||||
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the path.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !status) return <ErrorState message={error} />
|
||||
|
||||
const counts = status?.counts || null
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Spawn atlas
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
|
||||
It refreshes itself on every server start; everything here is for the times you don’t want
|
||||
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
|
||||
shard state, and stays complete while the shard is down.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{status?.pending && (
|
||||
<PendingReview
|
||||
pending={status.pending}
|
||||
busy={busy}
|
||||
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
|
||||
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
What is loaded
|
||||
</h3>
|
||||
<Row label="Imported">
|
||||
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
|
||||
</Row>
|
||||
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
|
||||
{counts && (
|
||||
<>
|
||||
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
|
||||
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
|
||||
<Row label="Regions / landmarks">
|
||||
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
|
||||
</Row>
|
||||
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="Tree readable">
|
||||
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||||
</Row>
|
||||
<Row label="Tree changed since import">
|
||||
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
ServUO tree
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
|
||||
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
|
||||
mount can move without a redeploy. Leave it blank to turn the atlas off.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/srv/servuo"
|
||||
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
|
||||
Save path
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Re-import
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Applies a map change without restarting. An unchanged tree costs nothing — the source files
|
||||
are hashed first and skipped when they match. A refresh that would remove a facet still
|
||||
comes back here for approval rather than being applied.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy || !status?.configured}
|
||||
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
|
||||
>
|
||||
{busy ? 'Working…' : 'Import now'}
|
||||
</button>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
|
||||
Re-import even if the tree is unchanged
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(msg || error) && (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
@@ -136,6 +136,114 @@ function Houses({ scope }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Admin security controls for one user: their trusted devices (view + revoke) and
|
||||
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
|
||||
function SecurityAdmin({ userId }) {
|
||||
const [devices, setDevices] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setDevices(await api.admin.userTrustedDevices(userId))
|
||||
} catch {
|
||||
setError('Could not load trusted devices.')
|
||||
}
|
||||
}, [userId])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function revoke(deviceId) {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.revokeUserTrustedDevice(userId, deviceId)
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not revoke that device.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAll() {
|
||||
if (!window.confirm('Revoke ALL of this user’s trusted devices?')) return
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.revokeAllUserTrustedDevices(userId)
|
||||
setMsg('All trusted devices revoked.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not revoke devices.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetMfa() {
|
||||
if (!window.confirm('Reset this user’s two-factor? This turns TOTP off, revokes their trusted devices, and clears their recovery codes so they can sign in with their password.')) return
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.resetUserMfa(userId)
|
||||
setMsg('Two-factor has been reset for this user.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not reset two-factor.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (d) => {
|
||||
const t = d ? new Date(d) : null
|
||||
return t && !Number.isNaN(t.getTime()) ? t.toLocaleDateString() : '—'
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Security & two-factor</SectionTitle>
|
||||
{devices == null ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||
) : devices.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No trusted devices.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: '0 0 14px', padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Revoke
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{devices && devices.length > 0 && (
|
||||
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Revoke all trusted devices
|
||||
</button>
|
||||
)}
|
||||
<button onClick={resetMfa} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||
Reset two-factor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 12, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 12, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ShardSections({ scope }) {
|
||||
return (
|
||||
<>
|
||||
@@ -187,6 +295,7 @@ export default function UserDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SecurityAdmin userId={id} />
|
||||
<ShardSections scope={scope} />
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -116,6 +119,7 @@ function TwoFactor({ account, reload }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
|
||||
|
||||
async function begin() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
@@ -131,8 +135,8 @@ function TwoFactor({ account, reload }) {
|
||||
async function confirm() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.player.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.')
|
||||
const res = await api.player.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not enable two-factor.')
|
||||
@@ -204,6 +208,11 @@ function TwoFactor({ account, reload }) {
|
||||
</div>
|
||||
)}
|
||||
<Note msg={msg} error={error} />
|
||||
{newCodes && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -416,6 +425,12 @@ export default function PlayerAccount() {
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
{account.totp_enabled && (
|
||||
<>
|
||||
<TrustedDevicesPanel />
|
||||
<RecoveryCodesPanel hasPassword={account.has_password !== false} />
|
||||
</>
|
||||
)}
|
||||
<LinkedAccounts />
|
||||
<ActiveDevices />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
@@ -34,6 +35,12 @@ export default function PlayerLogin() {
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [ssoTotp, setSsoTotp] = useState(false)
|
||||
const [trustDevice, setTrustDevice] = useState(false)
|
||||
const [useRecovery, setUseRecovery] = useState(false)
|
||||
// When trust was requested at login but the device cap is reached: show the
|
||||
// revoke-to-continue modal, then navigate on resolve. `pendingDest` holds where
|
||||
// to go once the prompt is dealt with.
|
||||
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest }
|
||||
|
||||
const [providers, setProviders] = useState([])
|
||||
const [canRegister, setCanRegister] = useState(false)
|
||||
@@ -101,22 +108,48 @@ export default function PlayerLogin() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (ssoTotp) {
|
||||
const { returnTo, redirect } = await ssoLoginTotp(code)
|
||||
// Trust works on the SSO second factor too. On the mobile bridge this page
|
||||
// is running inside the app's Custom Tab, so the cookie set here is what
|
||||
// lets the next app sign-in skip the code.
|
||||
const data = await ssoLoginTotp(code.trim(), { trustDevice })
|
||||
// Native SSO bridge (M9): a mobile 2FA completion returns an absolute
|
||||
// deep link (e.g. runicgateway://…) to hand the app its one-time code.
|
||||
// React Router can't navigate a custom scheme, so leave the SPA for it.
|
||||
if (redirect) {
|
||||
window.location.href = redirect
|
||||
// This wins over the trust-cap prompt: the sign-in itself succeeded and the
|
||||
// deep link is single-use, so stalling here to manage devices would strand
|
||||
// the app. An over-cap user simply isn't trusted and can prune the list
|
||||
// from Account → Trusted Devices.
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect
|
||||
return
|
||||
}
|
||||
navigate(returnTo || '/account', { replace: true })
|
||||
const to = data.returnTo || '/account'
|
||||
if (data.trustLimitReached) {
|
||||
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(to, { replace: true })
|
||||
} else {
|
||||
const u = await loginTotp(challenge, code)
|
||||
navigate(destFor(u), { replace: true })
|
||||
const entered = code.trim()
|
||||
const data = await loginTotp(challenge, useRecovery ? '' : entered, {
|
||||
recoveryCode: useRecovery ? entered : undefined,
|
||||
trustDevice,
|
||||
})
|
||||
const to = destFor(data.user)
|
||||
// Trust was requested but the device cap is reached: the session is already
|
||||
// issued, so prompt to revoke one before trusting, then navigate.
|
||||
if (data.trustLimitReached) {
|
||||
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(to, { 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.')
|
||||
const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
|
||||
setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
|
||||
setBusy(false)
|
||||
if (expired) {
|
||||
setStage('creds')
|
||||
@@ -169,13 +202,43 @@ export default function PlayerLogin() {
|
||||
</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>
|
||||
<>
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder={useRecovery ? 'xxxxx-xxxxx' : '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' }}>
|
||||
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
|
||||
</span>
|
||||
</label>
|
||||
{/* Offered on the SSO second factor too — the trust is on the device,
|
||||
not on how the first factor was proved. Inside the app's Custom Tab
|
||||
this is also what trusts the device for future native sign-ins. */}
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
Trust this device for 30 days (skip the code next time)
|
||||
</label>
|
||||
{/* Recovery codes remain password-login only: the SSO second step
|
||||
verifies an authenticator code against the staged challenge. */}
|
||||
{!ssoTotp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
|
||||
className="sans"
|
||||
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
|
||||
>
|
||||
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(error || (stage === 'creds' && ssoError)) && (
|
||||
@@ -208,6 +271,14 @@ export default function PlayerLogin() {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{trustLimit && (
|
||||
<TrustLimitModal
|
||||
devices={trustLimit.devices}
|
||||
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
|
||||
onCancel={() => navigate(trustLimit.dest, { replace: true })}
|
||||
/>
|
||||
)}
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
310
client/src/routes/public/Atlas.jsx
Normal file
310
client/src/routes/public/Atlas.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||
// spawn, where, and which champion altars are configured. There is no live feed
|
||||
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||
// shard's own files and stays complete while the shard is down.
|
||||
//
|
||||
// Facet names come from the shard's data, never from a list in this file. A
|
||||
// shard running custom maps gets its own names in the filter with no code
|
||||
// change (docs/link/v3.md §6.1 R2).
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
const TABS = [
|
||||
{ key: 'creatures', label: 'Creatures' },
|
||||
{ key: 'champions', label: 'Champion altars' },
|
||||
{ key: 'places', label: 'Places' },
|
||||
]
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
fontSize: '0.98rem',
|
||||
color: 'var(--head)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{creature.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{facets.length === 0
|
||||
? '—'
|
||||
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||
{num(creature.points)} spawners
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// The creature list owns its own paging rather than going through useAsync: a
|
||||
// "load more" appends to what is already on screen, which a hook that resets to
|
||||
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||
function Creatures({ q, facet }) {
|
||||
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (offset) => {
|
||||
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||
return page
|
||||
},
|
||||
[q, facet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, items: [], total: 0 })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.items.length)
|
||||
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is already on screen alone; the button
|
||||
// simply stays available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <Loading />
|
||||
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||
if (state.items.length === 0) {
|
||||
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.items.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.items.map((c) => (
|
||||
<CreatureCard key={c.slug} creature={c} />
|
||||
))}
|
||||
</div>
|
||||
{state.items.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
||||
// fed by the sidecar. Both exist; they are not the same thing.
|
||||
function Champions({ facet }) {
|
||||
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((champ) => (
|
||||
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||
{champ.label || champ.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{champ.facet}
|
||||
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||
// them into two tabs would make the visitor guess which list a name lives in.
|
||||
function Places({ q, facet }) {
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||
[q, facet],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return []
|
||||
const [regions, landmarks] = data
|
||||
return [
|
||||
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||
].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [data])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load places right now." />
|
||||
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Atlas() {
|
||||
const [tab, setTab] = useState('creatures')
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [facet, setFacet] = useState('')
|
||||
const meta = useAsync(() => api.atlas.meta())
|
||||
|
||||
// Debounced: typing "lizardman" should be one request, not nine.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
const facets = meta.data?.facets || []
|
||||
const counts = meta.data?.counts || null
|
||||
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title="Spawn atlas"
|
||||
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
/>
|
||||
|
||||
{/* The atlas is only as good as its placement rate, so the page states
|
||||
it rather than implying every spawner resolved to a named place. */}
|
||||
{counts && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||
: ''}
|
||||
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{TABS.map((t) => (
|
||||
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab !== 'champions' && (
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{facets.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||
All facets
|
||||
</Chip>
|
||||
{facets.map((f) => (
|
||||
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||
{f}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||
{!meta.error && !meta.loading && !imported && (
|
||||
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||
)}
|
||||
|
||||
{!meta.error && imported && (
|
||||
<>
|
||||
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||
{tab === 'champions' && <Champions facet={facet} />}
|
||||
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
201
client/src/routes/public/AtlasCreature.jsx
Normal file
201
client/src/routes/public/AtlasCreature.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||
// available underneath for the reader who actually wants a coordinate, but they
|
||||
// are secondary and collapsed by default.
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||
function delay(min, max) {
|
||||
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||
if (min === max) return fmt(min)
|
||||
return `${fmt(min)}–${fmt(max)}`
|
||||
}
|
||||
|
||||
function Panel({ title, right, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Places({ places }) {
|
||||
if (places.length === 0) {
|
||||
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{places.map((place) => (
|
||||
<div
|
||||
key={`${place.facet}:${place.label}`}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||
<span className="dim" style={{ flex: 'none' }}>
|
||||
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||
{num(place.maxAlive)} at once
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spawners({ spawners, truncated }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (spawners.length === 0) return null
|
||||
return (
|
||||
<Panel
|
||||
title="Individual spawners"
|
||||
right={
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||
>
|
||||
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spawners.map((s) => (
|
||||
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncated && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
Only the largest spawners are listed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AtlasCreature() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||
|
||||
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||
// and not a failure — a visitor following a stale link deserves to be told
|
||||
// that plainly rather than shown a generic error box.
|
||||
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||
|
||||
const facets = useMemo(
|
||||
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||
[data],
|
||||
)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
title="Where it spawns"
|
||||
right={
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Places places={data.places || []} />
|
||||
</Panel>
|
||||
|
||||
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||
|
||||
{data.alsoHere?.length > 0 && (
|
||||
<Panel title="Shares a spawner with">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '4px 11px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
240
client/src/routes/public/Leaderboards.jsx
Normal file
240
client/src/routes/public/Leaderboards.jsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||
// players build over months, and none of them visible anywhere but an in-game gump
|
||||
// until now.
|
||||
//
|
||||
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||
// system, so live frames are merged over the fetched set by system key rather than
|
||||
// replacing it.
|
||||
const POINTS_KINDS = new Set(['points.board'])
|
||||
|
||||
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||
// "Clean Up Britannia"). Better than showing a bare number.
|
||||
const humanise = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||
// system that has never appeared in either is simply absent.
|
||||
function mergeBoards(fetched, events) {
|
||||
const bySystem = new Map()
|
||||
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||
if (b && b.system) bySystem.set(b.system, b)
|
||||
}
|
||||
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]
|
||||
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||
}
|
||||
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||
}
|
||||
|
||||
function Medal({ rank }) {
|
||||
// Gold / silver / bronze for the podium, plain for the rest.
|
||||
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="display"
|
||||
style={{
|
||||
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||
// because the standing itself is the point.
|
||||
function Entry({ entry, best }) {
|
||||
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
<Medal rank={entry.rank} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.name || 'Name hidden'}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{num(entry.points)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Board({ board }) {
|
||||
const { siteTitle } = useSite()
|
||||
const top = Array.isArray(board.top) ? board.top : []
|
||||
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||
// which would render every bar as a stub.
|
||||
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{boardTitle(board)}
|
||||
</h2>
|
||||
{Number.isFinite(board.players) && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||
{num(board.players)} ranked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
// A board nobody has scored on still gets a row, so the page reads as a set
|
||||
// of standings waiting to be filled rather than a stack of blanks. It is
|
||||
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
|
||||
// a score goes — because a placeholder that looked like a real standing would
|
||||
// be a fabricated one. The first real entry replaces it.
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: 'var(--muted)', fontSize: '0.86rem',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{siteTitle}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</span>
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
Nobody has earned points here yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{top.map((entry) => (
|
||||
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
Maximum {num(board.maxPoints)} points
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Leaderboards() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.points())
|
||||
// Buffer generously: a single sweep can emit a frame for every system at once,
|
||||
// and a board dropped from the buffer would silently revert to its fetched copy.
|
||||
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const boards = useMemo(() => mergeBoards(data, events), [data, events])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return boards
|
||||
// Match the board name, the raw system key, or any ranked player on it — the
|
||||
// last is what makes the filter useful ("where do I appear?").
|
||||
return boards.filter(
|
||||
(b) =>
|
||||
boardTitle(b).toLowerCase().includes(q) ||
|
||||
String(b.system).toLowerCase().includes(q) ||
|
||||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
|
||||
)
|
||||
}, [boards, query])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader
|
||||
eyebrow="Live"
|
||||
title="Leaderboards"
|
||||
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
||||
/>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the leaderboards right now." />}
|
||||
|
||||
{!loading && !error && boards.length === 0 && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published any leaderboards yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && boards.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter by board or player name…"
|
||||
aria-label="Filter leaderboards"
|
||||
style={{ maxWidth: 340, marginBottom: 14 }}
|
||||
/>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
||||
{shown.map((board) => (
|
||||
<Board key={board.system} board={board} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
325
client/src/routes/public/Market.jsx
Normal file
325
client/src/routes/public/Market.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── The player-vendor marketplace ───────────────────────────────────────────
|
||||
//
|
||||
// What every player vendor on the shard is selling, for how much, and where it
|
||||
// is standing — the same index the in-game Vendor Search gump reads, honouring
|
||||
// the same per-vendor opt-out, reachable without logging in to the game.
|
||||
//
|
||||
// Three things this page must be honest about, all of them consequences of how
|
||||
// the data is gathered (docs/link/v3.md §8):
|
||||
//
|
||||
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
|
||||
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
|
||||
// A page that implied live prices would send people across the world to a
|
||||
// vendor whose item sold twenty minutes ago.
|
||||
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
|
||||
// publishes only the first N, and saying so beats presenting a partial shop
|
||||
// as complete.
|
||||
// • **An item may have no name.** On a shard whose operator has not converted
|
||||
// a cliloc table, `displayName` is null and the honest render is the item id
|
||||
// — not an invented name.
|
||||
//
|
||||
// There is deliberately no live feed here. The market feature's SSE stream ships
|
||||
// disabled: a firehose of whole vendor inventories would be the site's single
|
||||
// biggest bandwidth consumer, and nothing on this page needs it.
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const SORTS = [
|
||||
{ key: 'price_asc', label: 'Cheapest' },
|
||||
{ key: 'price_desc', label: 'Priciest' },
|
||||
{ key: 'recent', label: 'Recently seen' },
|
||||
]
|
||||
|
||||
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
|
||||
// this is a worst case rather than an average — which is the number worth
|
||||
// showing, because the one stale shop is the one that wastes a trip.
|
||||
function staleness(staleAt) {
|
||||
if (!staleAt) return null
|
||||
const ms = Date.now() - new Date(staleAt).getTime()
|
||||
if (!Number.isFinite(ms) || ms < 0) return null
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||
return `${Math.round(hours / 24)} days ago`
|
||||
}
|
||||
|
||||
// The item's name, or an honest statement that we do not have one. Never a
|
||||
// fabricated label — "Item 3922" would be indistinguishable from a real name.
|
||||
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ListingRow({ listing }) {
|
||||
const v = listing.vendor || {}
|
||||
// `location` is one field the admin can gate away wholesale, so everything
|
||||
// that reads from it has to tolerate its absence rather than assuming a map.
|
||||
const loc = v.location || null
|
||||
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
|
||||
{itemLabel(listing)}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{v.serial ? (
|
||||
<Link to={`/site/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
|
||||
{v.shopName || 'an unnamed shop'}
|
||||
</Link>
|
||||
) : (
|
||||
v.shopName || 'an unnamed shop'
|
||||
)}
|
||||
{v.ownerName ? ` · ${v.ownerName}` : ''}
|
||||
{where ? ` · ${where}` : ''}
|
||||
{/* Priced by the container it sits in, exactly as the in-game search
|
||||
reports it — the price buys the whole container, not this item. */}
|
||||
{listing.child ? ' · sold with its container' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Market() {
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [map, setMap] = useState('')
|
||||
const [region, setRegion] = useState('')
|
||||
const [sort, setSort] = useState('price_asc')
|
||||
const [minPrice, setMinPrice] = useState('')
|
||||
const [maxPrice, setMaxPrice] = useState('')
|
||||
// Applied prices are separate from the typed ones so the search fires when the
|
||||
// user is done, not on every digit of "250000".
|
||||
const [prices, setPrices] = useState({ min: '', max: '' })
|
||||
|
||||
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const meta = useAsync(() => api.shard.marketMeta())
|
||||
|
||||
// Debounced: typing "vanquishing" should be one request, not eleven — and the
|
||||
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [minPrice, maxPrice])
|
||||
|
||||
const load = useCallback(
|
||||
(offset) =>
|
||||
api.shard.market({
|
||||
q,
|
||||
map,
|
||||
region,
|
||||
sort,
|
||||
minPrice: prices.min,
|
||||
maxPrice: prices.max,
|
||||
limit: PAGE,
|
||||
offset,
|
||||
}),
|
||||
[q, map, region, sort, prices],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (!alive) return
|
||||
setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
listings: page.listings || [],
|
||||
total: page.total || 0,
|
||||
staleAt: page.staleAt || null,
|
||||
})
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.listings.length)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
listings: [...s.listings, ...(page.listings || [])],
|
||||
total: page.total ?? s.total,
|
||||
staleAt: page.staleAt ?? s.staleAt,
|
||||
}))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is on screen alone; the button stays
|
||||
// available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const maps = meta.data?.maps || []
|
||||
const regions = meta.data?.regions || []
|
||||
const age = staleness(state.staleAt)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Marketplace"
|
||||
title="Player vendors"
|
||||
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
|
||||
/>
|
||||
|
||||
{/* Not decoration. The sweep is round-robin, so the index is inherently
|
||||
up to one full cycle old and the page has to say so. */}
|
||||
{age && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
Prices last refreshed {age}
|
||||
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
|
||||
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Search listings…"
|
||||
style={{ width: '100%', marginBottom: 10 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
placeholder="Min price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
placeholder="Max price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{SORTS.map((s) => (
|
||||
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
|
||||
{s.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Facet and region names come from the shard's own data, never a list in
|
||||
this file — a shard running custom maps gets its own names here with
|
||||
no code change (docs/link/v3.md §6.1 R2). */}
|
||||
{maps.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
|
||||
{maps.map((m) => (
|
||||
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regions.length > 0 && (
|
||||
<select
|
||||
className="input"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
style={{ width: '100%', marginBottom: 18 }}
|
||||
>
|
||||
<option value="">Anywhere</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{state.loading && <Loading />}
|
||||
{state.error && <ErrorState message="Could not load the marketplace right now." />}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length === 0 && (
|
||||
<EmptyState>
|
||||
{meta.data?.vendors
|
||||
? 'Nothing on the shard matches that.'
|
||||
: 'No player vendors have been indexed yet.'}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length > 0 && (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.listings.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.listings.map((l) => (
|
||||
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
|
||||
))}
|
||||
</div>
|
||||
{state.listings.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
102
client/src/routes/public/MarketVendor.jsx
Normal file
102
client/src/routes/public/MarketVendor.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One player vendor: where to find it and everything it is selling.
|
||||
//
|
||||
// The page a search result points at. Two states it has to render honestly and
|
||||
// which the search list cannot (docs/link/v3.md §8):
|
||||
//
|
||||
// • `truncated` — the shop holds more than the shard publishes per frame. A
|
||||
// commodity reseller with thousands of stacks is a real thing, and showing
|
||||
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
|
||||
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
|
||||
// which case there is nothing to render and the page says so rather than
|
||||
// showing an empty coordinate.
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
|
||||
|
||||
export default function MarketVendor() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body"><Loading /></div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const loc = data.location || null
|
||||
const items = data.items || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
|
||||
title={data.shopName || 'An unnamed shop'}
|
||||
lead={
|
||||
loc
|
||||
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
|
||||
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
|
||||
: 'This shard does not publish vendor locations.'
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
|
||||
{data.truncated
|
||||
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
|
||||
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
|
||||
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState>This shop has nothing priced for sale.</EmptyState>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((i) => (
|
||||
<div
|
||||
key={i.serial}
|
||||
className="panel"
|
||||
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
|
||||
>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
|
||||
{itemLabel(i)}
|
||||
{i.child ? <span className="dim"> · sold with its container</span> : null}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{num(i.price)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: 20 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
341
client/src/routes/public/Rules.jsx
Normal file
341
client/src/routes/public/Rules.jsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useMemo } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
|
||||
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
|
||||
// there is nothing to merge — latest wins).
|
||||
//
|
||||
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
|
||||
// it cannot drift the way a hand-written rules page does. That is the whole point
|
||||
// of the feature, and the page says so.
|
||||
const RULESET_KINDS = new Set(['world.ruleset'])
|
||||
|
||||
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
|
||||
// skill. Showing the raw number would be actively misleading.
|
||||
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
|
||||
|
||||
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
|
||||
|
||||
// The systems block is a flat bag of booleans; these are their display names, and
|
||||
// the order here is the order they render. A key the shard sends that we don't
|
||||
// know about still renders, humanised, rather than being silently dropped — a new
|
||||
// plugin must not go invisible against an older client.
|
||||
const SYSTEM_LABELS = {
|
||||
cityLoyalty: 'City Loyalty (governors)',
|
||||
vvv: 'Vice vs Virtue',
|
||||
factions: 'Factions',
|
||||
siege: 'Siege ruleset',
|
||||
chat: 'In-game chat',
|
||||
store: 'Ultima Store',
|
||||
dailyRares: 'Daily rares',
|
||||
honesty: 'Honesty virtue',
|
||||
shadowguard: 'Shadowguard',
|
||||
treasureMaps: 'Treasure maps',
|
||||
vetRewards: 'Veteran rewards',
|
||||
testCenter: 'Test Center',
|
||||
}
|
||||
|
||||
const humanise = (key) =>
|
||||
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
function Panel({ title, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<h2
|
||||
className="display"
|
||||
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// A label/value row. Rows whose value is null are dropped by the caller, so a
|
||||
// block never renders a dangling label for something the shard didn't publish.
|
||||
function Row({ label, value }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
|
||||
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Rows({ items }) {
|
||||
const rows = items.filter(([, value]) => value !== null && value !== undefined)
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
{rows.map(([label, value]) => (
|
||||
<Row key={label} label={label} value={value} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemPill({ label, on }) {
|
||||
const color = on ? '#8fdcae' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
fontSize: '0.8rem',
|
||||
padding: '5px 11px',
|
||||
borderRadius: 999,
|
||||
color,
|
||||
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
|
||||
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Systems({ systems }) {
|
||||
// Known keys first in their declared order, then anything the shard added that
|
||||
// this build doesn't know about.
|
||||
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
|
||||
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
|
||||
const keys = [...known, ...extra]
|
||||
if (keys.length === 0) return null
|
||||
return (
|
||||
<Panel title="Systems">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{keys.map((k) => (
|
||||
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Caps({ caps }) {
|
||||
return (
|
||||
<Panel title="Skill & stat caps">
|
||||
<Rows
|
||||
items={[
|
||||
['Individual skill cap', tenths(caps.skill)],
|
||||
['Total skill cap', tenths(caps.totalSkill)],
|
||||
['Total stat cap', num(caps.stat)],
|
||||
['Strength cap', num(caps.str)],
|
||||
['Dexterity cap', num(caps.dex)],
|
||||
['Intelligence cap', num(caps.int)],
|
||||
['Strength max', num(caps.strMax)],
|
||||
['Dexterity max', num(caps.dexMax)],
|
||||
['Intelligence max', num(caps.intMax)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountsAndHousing({ accounts, housing, vetRewards }) {
|
||||
const items = []
|
||||
if (accounts) {
|
||||
items.push(['Accounts per IP', num(accounts.perIp)])
|
||||
items.push(['Character slots', num(accounts.charSlots)])
|
||||
items.push([
|
||||
'In-game account creation',
|
||||
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
|
||||
])
|
||||
}
|
||||
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
|
||||
if (vetRewards?.enabled) {
|
||||
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
|
||||
? `${vetRewards.rewardIntervalDays} days`
|
||||
: null])
|
||||
}
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<Panel title="Accounts & housing">
|
||||
<Rows items={items} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Champions({ champions }) {
|
||||
const t = champions.rankThresholds
|
||||
return (
|
||||
<Panel title="Champion spawns">
|
||||
<Rows
|
||||
items={[
|
||||
['Power scrolls per spawn', num(champions.powerScrolls)],
|
||||
['Stat scrolls per spawn', num(champions.statScrolls)],
|
||||
['Scroll drop chance', pct(champions.scrollChance)],
|
||||
['Transcendence chance', pct(champions.transcendenceChance)],
|
||||
[
|
||||
'Red skulls per rank',
|
||||
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Felucca({ loot }) {
|
||||
return (
|
||||
<Panel title="Felucca bonuses">
|
||||
<Rows
|
||||
items={[
|
||||
['Luck bonus', num(loot.feluccaLuckBonus)],
|
||||
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
|
||||
['Max item properties', num(loot.feluccaMaxProps)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Vendors({ vendors }) {
|
||||
return (
|
||||
<Panel title="Vendors">
|
||||
<Rows
|
||||
items={[
|
||||
['Restock delay', vendors.restockDelayMinutes
|
||||
? `${vendors.restockDelayMinutes} min`
|
||||
: null],
|
||||
['Max items sold at once', num(vendors.maxSell)],
|
||||
['Economy stock amount', num(vendors.economyStockAmount)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Pvp({ vvv }) {
|
||||
return (
|
||||
<Panel title="Vice vs Virtue">
|
||||
<Rows
|
||||
items={[
|
||||
['Starting silver', num(vvv.startSilver)],
|
||||
['Enhanced rules', vvv.enhancedRules === undefined
|
||||
? null
|
||||
: vvv.enhancedRules ? 'On' : 'Off'],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Schedule({ schedule }) {
|
||||
const items = []
|
||||
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
|
||||
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
|
||||
} else if (schedule.autoSaveEnabled === false) {
|
||||
items.push(['World save', 'Disabled'])
|
||||
}
|
||||
if (schedule.autoRestartEnabled) {
|
||||
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
|
||||
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
|
||||
items.push(['Automatic restart', `${h}:${m} server time`])
|
||||
if (schedule.autoRestartFrequencyHours) {
|
||||
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
|
||||
}
|
||||
}
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<Panel title="Save & restart schedule">
|
||||
<Rows items={items} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Rules() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.ruleset())
|
||||
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
|
||||
|
||||
// The newest world.ruleset on the feed wins outright over the fetched copy —
|
||||
// the frame is a complete ruleset, not a delta.
|
||||
const ruleset = useMemo(() => events[0] || data || null, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader
|
||||
eyebrow="Live"
|
||||
title="Shard ruleset"
|
||||
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
|
||||
/>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the shard ruleset right now." />}
|
||||
|
||||
{!loading && !error && !ruleset && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published its ruleset yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && ruleset && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel title="Shard">
|
||||
<Rows
|
||||
items={[
|
||||
['Name', ruleset.shard || null],
|
||||
['Expansion', ruleset.expansion || null],
|
||||
['Connect', ruleset.connect || null],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{ruleset.systems && <Systems systems={ruleset.systems} />}
|
||||
{ruleset.caps && <Caps caps={ruleset.caps} />}
|
||||
<AccountsAndHousing
|
||||
accounts={ruleset.accounts}
|
||||
housing={ruleset.housing}
|
||||
vetRewards={ruleset.vetRewards}
|
||||
/>
|
||||
{ruleset.champions && <Champions champions={ruleset.champions} />}
|
||||
{ruleset.loot && <Felucca loot={ruleset.loot} />}
|
||||
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
|
||||
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
|
||||
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -140,3 +140,44 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||
})
|
||||
|
||||
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
||||
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
|
||||
// content parsed from the shard's own files, so it must not look sidecar-backed.
|
||||
// Asserted here because the split is a design decision, not an accident of
|
||||
// spelling.
|
||||
test('atlas reads hit /public/atlas, not /public/shard', async () => {
|
||||
willReply({ body: { creatures: [] } })
|
||||
await api.atlas.creatures()
|
||||
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
|
||||
})
|
||||
|
||||
test('atlas.creatures() sends only the filters that are set', async () => {
|
||||
willReply({ body: { creatures: [] } })
|
||||
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
|
||||
const url = new URL(calls[0].url, 'http://x')
|
||||
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
|
||||
assert.equal(url.searchParams.get('q'), 'lizard man')
|
||||
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
|
||||
assert.equal(url.searchParams.get('limit'), '25')
|
||||
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
|
||||
})
|
||||
|
||||
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
|
||||
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
|
||||
})
|
||||
|
||||
test('admin atlas actions use the right methods and bodies', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.admin.atlas.import(true)
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
|
||||
|
||||
willReply({ body: {} })
|
||||
await api.admin.atlas.setPath('/srv/servuo')
|
||||
assert.equal(calls[1].opts.method, 'PUT')
|
||||
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
|
||||
})
|
||||
|
||||
@@ -74,9 +74,20 @@ services:
|
||||
volumes:
|
||||
- ntfydata:/var/lib/ntfy
|
||||
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro
|
||||
# No published host port — devices reach ntfy through the public reverse proxy
|
||||
# on its own hostname; the backend publisher reaches it over the private
|
||||
# compose network. Never publish this directly.
|
||||
# Published so the PUBLIC reverse proxy (Pangolin) can forward the
|
||||
# notification subdomain here. Pangolin lives OUTSIDE the compose network and
|
||||
# reaches every service through a published host port — never by joining the
|
||||
# internal network — exactly like `app` above (3000). So ntfy must publish a
|
||||
# port too: the reverse proxy maps notify.<host> -> host:NTFY_HOST_PORT ->
|
||||
# ntfy:80. Unlike INTERNAL_PORT / the bot, ntfy is DEVICE-facing, so it is
|
||||
# SUPPOSED to be reachable through the proxy. Binds 0.0.0.0 (no 127.0.0.1
|
||||
# prefix) so Pangolin can reach the container. Both the app (SSE subscribe) and
|
||||
# the backend (POSTing content-free tickles to each device's registered
|
||||
# endpoint) reach ntfy on this same public origin — NTFY_ALLOWED_ORIGINS pins
|
||||
# it — so all ntfy traffic flows through the proxy; there is no separate
|
||||
# internal publish port.
|
||||
ports:
|
||||
- "${NTFY_HOST_PORT:-2586}:80"
|
||||
|
||||
bot:
|
||||
# Same as app: prebuilt bot image, pulled in production. Build locally via
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
# a placeholder for a bare `ntfy serve`.
|
||||
base-url: "https://ntfy.localhost"
|
||||
|
||||
# Served on the private compose network; the public reverse proxy terminates TLS
|
||||
# and forwards to this port. docker-compose.yml publishes NO host port for ntfy.
|
||||
# ntfy listens on :80 inside the container. docker-compose.yml publishes this on
|
||||
# a host port (NTFY_HOST_PORT, default 2586) so the public reverse proxy — which
|
||||
# lives OUTSIDE the compose network — can terminate TLS and forward the
|
||||
# notification subdomain to it. Both the app (SSE subscribe) and the backend
|
||||
# (POSTing content-free tickles to registered device endpoints) reach ntfy on
|
||||
# that public origin, so all traffic flows through the proxy.
|
||||
listen-http: ":80"
|
||||
behind-proxy: true
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@ JWT_EXPIRES_IN=1d
|
||||
COOKIE_SECURE=auto
|
||||
COOKIE_NAME=rg_token
|
||||
|
||||
# Trusted-device MFA ("Trust this device"). The trust cookie's name, how long a
|
||||
# device stays trusted (skips the TOTP step, never the password), the per-user cap
|
||||
# (no silent pruning — an over-cap trust is refused), and how many single-use
|
||||
# recovery codes are generated at 2FA enrollment.
|
||||
TRUST_COOKIE_NAME=rg_trust
|
||||
TRUSTED_DEVICE_TTL_DAYS=30
|
||||
MAX_TRUSTED_DEVICES=10
|
||||
RECOVERY_CODE_COUNT=10
|
||||
|
||||
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
||||
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
||||
# insecure key is derived from JWT_SECRET if unset (with a warning).
|
||||
|
||||
24
server/db/data/spawnAtlas.art.example.json
Normal file
24
server/db/data/spawnAtlas.art.example.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_comment": [
|
||||
"OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to",
|
||||
"spawnAtlas.art.json (same directory) and edit it, then restart the server or run",
|
||||
"`npm run atlas:import` — the art map is read on every atlas refresh.",
|
||||
"",
|
||||
"This project ships NO creature artwork and never will. UO sprites live in your",
|
||||
"own client's .mul/.uop files and are yours to extract, not ours to redistribute.",
|
||||
"If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's",
|
||||
"tooling, or any art extractor), drop the images under server/uploads/atlas/, and",
|
||||
"map each creature slug to its file name here.",
|
||||
"",
|
||||
"Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map",
|
||||
"nor the images can be committed by accident.",
|
||||
"",
|
||||
"Keys are creature slugs, as reported by the atlas API and derived from the type",
|
||||
"names in your own shard's Spawns/*.xml. Values are file names relative to",
|
||||
"server/uploads/atlas/. Any creature with no entry here simply renders without",
|
||||
"art — that is the default and fully supported state, not a degraded one."
|
||||
],
|
||||
"lizardman": "lizardman.png",
|
||||
"orc": "orc.png",
|
||||
"dragon": "dragon.png"
|
||||
}
|
||||
@@ -215,6 +215,9 @@ CREATE TABLE IF NOT EXISTS mobile_auth_sessions (
|
||||
state VARCHAR(255) NOT NULL, -- app-generated opaque CSRF value, echoed to the app
|
||||
status ENUM('pending','completed','consumed') NOT NULL DEFAULT 'pending',
|
||||
user_id INT NULL, -- set once SSO resolves the account
|
||||
trust_device TINYINT(1) NOT NULL DEFAULT 0, -- user ticked "trust this device" on the Custom Tab TOTP form;
|
||||
-- a BOOLEAN only — the trust token itself is minted at /exchange
|
||||
-- and returned over that app→server call, never stored here
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL, -- ~10 min (one redirect round-trip incl. TOTP)
|
||||
used_at DATETIME NULL, -- stamped at exchange
|
||||
@@ -250,6 +253,50 @@ CREATE TABLE IF NOT EXISTS revoked_sessions (
|
||||
INDEX idx_revoked_sessions_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Trusted devices for MFA (opt-in "Trust this device"). A trusted device lets a
|
||||
-- browser/app SKIP the TOTP step at login — never the password. Pattern-identical
|
||||
-- to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust
|
||||
-- cookie on web, EncryptedSharedPreferences on mobile) and only its sha256 hash is
|
||||
-- stored here (token_hash UNIQUE, so the login path can look a device up in O(1)).
|
||||
-- sha256 (not bcrypt) because the token is a 256-bit random value looked up BY its
|
||||
-- hash — a per-row salt would break the index lookup. Trust is consulted only at
|
||||
-- the login/password step, never at token refresh, and is revoked on untrust /
|
||||
-- password change/reset / TOTP disable. Capped at 10 rows per user (enforced in
|
||||
-- application code — no silent pruning). See docs/website/TRUSTED_DEVICES_MFA.md.
|
||||
CREATE TABLE IF NOT EXISTS trusted_devices (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque trust token
|
||||
platform ENUM('web','mobile') NOT NULL DEFAULT 'web',
|
||||
device_name VARCHAR(100) NULL, -- friendly label for the Trusted Devices list
|
||||
device_hash VARCHAR(32) NULL, -- best-effort UA+IP (sessionMeta) — display only
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL, -- stamped when trust is honored at login
|
||||
expires_at DATETIME NOT NULL, -- created_at + 30d
|
||||
revoked_at DATETIME NULL,
|
||||
CONSTRAINT fk_td_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_td_user (user_id),
|
||||
INDEX idx_td_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Single-use recovery (backup) codes for MFA. Generated at TOTP enrollment (10 at a
|
||||
-- time, shown to the user ONCE) so a user who loses their authenticator can complete
|
||||
-- login without an admin reset. code_hash is a BCRYPT hash (not sha256): a recovery
|
||||
-- code is a human-typed, lower-entropy fallback credential — the closest analogue to
|
||||
-- a password — and there is no hash-lookup constraint (we fetch the user's <=10 rows
|
||||
-- and bcrypt.compare each, exactly like password verification). Cleared wholesale on
|
||||
-- TOTP disable / password change/reset. See docs/website/TRUSTED_DEVICES_MFA.md.
|
||||
CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
code_hash VARCHAR(72) NOT NULL, -- bcrypt hash of one recovery code
|
||||
used_at DATETIME NULL, -- single-use marker
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_rc_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
||||
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
||||
-- client secrets are, and is only ever decrypted server-side to push to the
|
||||
@@ -312,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) NULL,
|
||||
auth_token_enc TEXT NULL,
|
||||
protocol INT NOT NULL DEFAULT 1,
|
||||
protocol INT NOT NULL DEFAULT 3,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
@@ -550,6 +597,142 @@ CREATE TABLE IF NOT EXISTS shard_presence (
|
||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row
|
||||
-- (id = 1) holding the latest frame: expansion, which optional systems are on,
|
||||
-- skill/stat caps, account and house limits, champion scroll rules, the
|
||||
-- save/restart schedule. The shard re-emits it on every sidecar connect, so this
|
||||
-- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which
|
||||
-- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a
|
||||
-- .cfg". No row at all means the shard has never published one — served as null,
|
||||
-- which the rules page renders differently from a published ruleset.
|
||||
CREATE TABLE IF NOT EXISTS shard_ruleset (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
rev VARCHAR(32) NULL,
|
||||
expansion VARCHAR(16) NULL, -- hoisted for cheap display
|
||||
payload JSON NOT NULL, -- the whole world.ruleset frame
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
|
||||
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
|
||||
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
|
||||
-- players accumulate over months.
|
||||
--
|
||||
-- The top-N list stays inside `payload` rather than being normalized into a
|
||||
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
|
||||
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
|
||||
-- buy nothing until something needs a per-character reverse lookup, and a
|
||||
-- character's own standings already ride inside char.profile instead.
|
||||
--
|
||||
-- No delete path: the shard's set of systems is fixed at startup, so there is no
|
||||
-- points.remove to mirror.
|
||||
CREATE TABLE IF NOT EXISTS shard_points_boards (
|
||||
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
|
||||
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
|
||||
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
|
||||
max_points BIGINT NULL,
|
||||
players INT NULL, -- players actually holding points in this system
|
||||
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
|
||||
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
||||
-- vendor and one per priced listing, so the site can offer the search the in-game
|
||||
-- Vendor Search gump offers — from outside the game.
|
||||
--
|
||||
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
||||
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
||||
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
|
||||
-- keeping with every other shard_* table: the ingest transaction is what keeps
|
||||
-- them consistent, and an FK would turn a malformed frame into a failed write
|
||||
-- rather than a dropped row.
|
||||
--
|
||||
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
||||
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendors (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
||||
shop_name VARCHAR(160) NULL,
|
||||
owner_serial VARCHAR(20) NULL,
|
||||
owner_name VARCHAR(64) NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
region VARCHAR(80) NULL,
|
||||
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
||||
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
||||
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
||||
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_vendors_owner (owner_name),
|
||||
INDEX idx_shard_vendors_map (map),
|
||||
INDEX idx_shard_vendors_region (region),
|
||||
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
||||
-- round-robin sweep means the oldest row is how far behind the index can be.
|
||||
INDEX idx_shard_vendors_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
||||
-- whole — these are the searchable rows the whole feature exists for, so they are
|
||||
-- normalized rather than left inside a payload column, and there is no payload
|
||||
-- column on shard_vendors at all.
|
||||
--
|
||||
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
||||
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
||||
-- at query time would make the cliloc table a join on the hot path AND make
|
||||
-- search-by-name impossible. Resolving once on write buys the index. It is
|
||||
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
||||
-- re-send an unchanged shop just because the site learned what its items are
|
||||
-- called.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
vendor_serial VARCHAR(20) NOT NULL,
|
||||
serial VARCHAR(20) NOT NULL,
|
||||
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
||||
hue INT NOT NULL DEFAULT 0,
|
||||
amount INT NOT NULL DEFAULT 1,
|
||||
price BIGINT NOT NULL DEFAULT 0,
|
||||
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
||||
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
||||
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
||||
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
||||
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
||||
INDEX idx_shard_vendor_items_price (price),
|
||||
INDEX idx_shard_vendor_items_item (item_id),
|
||||
INDEX idx_shard_vendor_items_name (display_name),
|
||||
-- Search filters on name and sorts on price; the composite covers the common
|
||||
-- "cheapest matching X" without a filesort over the whole table.
|
||||
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
||||
-- per feature; an absent row means "use the compiled default", and the compiled
|
||||
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
||||
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
|
||||
-- docs/link/v3.md §3 for the contract.
|
||||
--
|
||||
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
|
||||
-- stream whether this feature's kinds fan out over SSE at all (the market
|
||||
-- index ships with this off: no page needs a live firehose of
|
||||
-- whole vendor inventories)
|
||||
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
|
||||
-- `webId` are admin-only always and are rejected here — they are
|
||||
-- not in-game visible and are deliberately not configurable.
|
||||
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
|
||||
feature VARCHAR(48) NOT NULL PRIMARY KEY,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
|
||||
stream TINYINT(1) NOT NULL DEFAULT 1,
|
||||
field_rules JSON NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
||||
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
||||
-- which creates their website user at that role (and optionally a linked game
|
||||
@@ -952,6 +1135,189 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
|
||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||
-- comes from the sidecar — it is imported from a committed artifact built off a
|
||||
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
||||
-- these tables stay populated whether the shard is up or not.
|
||||
--
|
||||
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
||||
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
||||
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
||||
|
||||
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
||||
-- each type's own MX across every point that spawns it (how many exist at once);
|
||||
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
||||
-- live" both answer without touching shard_spawn_points.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
||||
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
||||
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
||||
total INT NOT NULL DEFAULT 0,
|
||||
points INT NOT NULL DEFAULT 0,
|
||||
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
||||
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
||||
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
||||
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
||||
-- art when this is NULL, which is the normal case.
|
||||
art VARCHAR(255) NULL,
|
||||
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
||||
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
||||
INDEX idx_shard_spawn_creatures_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
||||
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
||||
-- the resolved display string (region, else landmark, else 'Wilderness').
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
height INT NOT NULL DEFAULT 0,
|
||||
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
||||
max_count INT NOT NULL DEFAULT 0,
|
||||
min_delay INT NOT NULL DEFAULT 0,
|
||||
max_delay INT NOT NULL DEFAULT 0,
|
||||
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
||||
tod_end INT NOT NULL DEFAULT 0,
|
||||
tod_mode INT NOT NULL DEFAULT 0,
|
||||
region VARCHAR(120) NULL,
|
||||
landmark VARCHAR(120) NULL,
|
||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||
INDEX idx_shard_spawn_points_facet (facet),
|
||||
INDEX idx_shard_spawn_points_label (label)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The many-to-many between the two above: one spawner commonly carries several
|
||||
-- types (a single Trammel point spawns six), each with its own max. This is how
|
||||
-- /atlas/creatures/:slug finds the places a creature appears.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
||||
point_id INT NOT NULL,
|
||||
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
||||
max_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (point_id, slug),
|
||||
INDEX idx_shard_spawn_point_types_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
||||
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
||||
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
||||
CREATE TABLE IF NOT EXISTS shard_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
type VARCHAR(80) NULL, -- ServUO region class
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
||||
rects JSON NULL,
|
||||
INDEX idx_shard_regions_facet (facet),
|
||||
INDEX idx_shard_regions_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
||||
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
||||
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
||||
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(120) NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
INDEX idx_shard_landmarks_facet (facet),
|
||||
INDEX idx_shard_landmarks_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
||||
type VARCHAR(80) NULL, -- '' when randomised per activation
|
||||
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
radius INT NOT NULL DEFAULT 0,
|
||||
label VARCHAR(120) NULL, -- resolved place name
|
||||
INDEX idx_shard_champion_spawns_facet (facet)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- UO's localization table: cliloc id -> display string. Items carry a
|
||||
-- `LabelNumber` rather than a name, so without this the site can only render
|
||||
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
|
||||
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
|
||||
-- was never the missing piece, the table was.
|
||||
--
|
||||
-- Sourced from a file the OPERATOR converts once from their own UO client and
|
||||
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
|
||||
-- is committed, the same rule the spawn atlas and the creature art map follow.
|
||||
-- A shard with no cliloc file configured simply renders item ids, which is what
|
||||
-- it did before this table existed.
|
||||
--
|
||||
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
|
||||
-- property descriptions, and truncating them silently would be worse than
|
||||
-- storing them. Item NAMES are all short — the index that matters for search is
|
||||
-- on the denormalized `shard_vendor_items.display_name`, not here.
|
||||
CREATE TABLE IF NOT EXISTS shard_clilocs (
|
||||
number INT NOT NULL PRIMARY KEY,
|
||||
flag SMALLINT NOT NULL DEFAULT 0,
|
||||
text TEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
|
||||
-- file, its sha256, the entry count and the parser version. The boot path
|
||||
-- compares the stored hash against the file on disk and skips the parse when
|
||||
-- they match, which is every restart that did not follow a client patch.
|
||||
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||
-- is behind the committed artifact.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||
-- NOT applied, because it would remove a facet the site currently serves.
|
||||
--
|
||||
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||
-- blocked by it: the site comes up serving the atlas it already had.
|
||||
--
|
||||
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||
--
|
||||
-- `rejected` is remembered against those exact source hashes so a declined
|
||||
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||
-- hashes and asks again.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||
payload JSON NOT NULL, -- source hashes + facet diff
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
@@ -1025,3 +1391,25 @@ ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NUL
|
||||
-- self-service list. Both nullable and additive; existing rows get them here.
|
||||
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS device_name VARCHAR(100) NULL;
|
||||
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME NULL;
|
||||
|
||||
-- SSO trusted devices: records that the user ticked "trust this device" on the
|
||||
-- Custom Tab TOTP form, so /auth/mobile/sso/exchange knows to mint the app's own
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
|
||||
-- points.board, vendor.listing), so the pinned version an existing install
|
||||
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
|
||||
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
|
||||
-- created before the bump (idempotent, like the other MODIFYs here).
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
|
||||
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
|
||||
-- must be one-shot: an operator who deliberately pins an older sidecar in
|
||||
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
|
||||
-- it fire once — written after the UPDATE, and on a fresh install (no
|
||||
-- uo_link_config row yet) it is simply written with nothing to update.
|
||||
UPDATE uo_link_config SET protocol = 3
|
||||
WHERE id = 1 AND protocol < 3
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"dev": "nodemon src/server.js",
|
||||
"seed": "node db/seed.js",
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
2157
server/routes.guards.json
Normal file
2157
server/routes.guards.json
Normal file
File diff suppressed because it is too large
Load Diff
907
server/routes.manifest.json
Normal file
907
server/routes.manifest.json
Normal file
@@ -0,0 +1,907 @@
|
||||
{
|
||||
"$comment": "Generated route inventory - the authoritative freeze of the URL surface. Regenerate with `npm run routes:manifest` in website/server; a domain-split PR must produce a zero-line diff here.",
|
||||
"public": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/.well-known/assetlinks.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/csp-report"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/docs.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/health"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/activity"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/auth/providers"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/auth/providers"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/auth/providers/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/auth/providers/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/bot-activity"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/bot-activity/unban"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/dashboard"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/discord-bot/config"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/discord-bot/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/config"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/email/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/callback"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/disconnect"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/test"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/invites"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/invites/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/appeals"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/appeals/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/appeals/:id/claim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/appeals/:id/resolve"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/filter-hits"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/members"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/recent"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/search"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/spam-hits"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/stats/summary"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/actions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/appeals"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/pages"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/pages"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/pages/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/pages/:id"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/pages/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/pages/:id/preview"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/pages/:id/unprotect"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/posts"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/posts"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/posts/:id/announce"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/posts/:id/announce/retry"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/posts/:id/publish"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/posts/upload"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/atlas"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/approve"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/import"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/atlas/path"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/reject"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/audit"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/ban"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/broadcast"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/char/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/clilocs"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/clilocs/import"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/clilocs/path"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/kick"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/link"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/pages"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/pages/:id/close"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/pages/:id/respond"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/roster/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/sales"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/unban"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/vendors/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/visibility"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/visibility"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/site-mode"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/uo-link/config"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/uo-link/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/uo-link/stream"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uo-link/towncrier"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/uo-link/towncrier/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uploads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/users/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/mfa/reset"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/shard/link/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/online"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/sales"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/standing"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/wiki"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/wiki/:slug/publish"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/:slug/revisions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/:slug/revisions/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/wiki/:slug/revisions/:id/restore"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/categories"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/wiki/categories"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/wiki/categories/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/wiki/categories/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/invite/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/invite/:token/accept"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/login"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/login/totp"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/logout"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/password"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/recovery-codes/generate"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/recovery-codes/status"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/devices"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/devices"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/devices/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/streams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/sessions/:id"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/trusted-devices/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/login"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/logout"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/refresh"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/sso/exchange"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/mobile/sso/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/password/forgot"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/password/reset/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/password/reset/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/providers"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/register"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/sso/:provider/callback"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/sso/:provider/link"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/sso/:provider/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/sso/totp"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/password"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/appeals"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/appeals/:id/withdraw"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals/eligible"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/shard/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/char/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/shard/link"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/roster/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/sales"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/vendors/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/champions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/landmarks"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/meta"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/regions"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/posts/:category"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/posts/:category/:idOrSlug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/settings"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/champs"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/economy"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/features"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/feed"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/governors"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/governors/:city/history"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/guilds"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/idoc"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/meta"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/vendors/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/ruleset"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/status"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/stream"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/status"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/version"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/categories"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/tags"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/health"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/bot-config"
|
||||
}
|
||||
]
|
||||
}
|
||||
127
server/scripts/importSpawnAtlas.js
Normal file
127
server/scripts/importSpawnAtlas.js
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Refresh the spawn atlas from a ServUO tree, from the command line.
|
||||
//
|
||||
// npm run atlas:import # use the configured path
|
||||
// npm run atlas:import -- --servuo <path> # override it for this run
|
||||
// npm run atlas:import -- --force # reimport even if unchanged
|
||||
// npm run atlas:import -- --approve # apply a staged refresh
|
||||
// npm run atlas:import -- --status # report without changing anything
|
||||
//
|
||||
// The server does this itself on every boot (see `shardAtlas.refreshOnBoot`), so
|
||||
// this is for operators who want to apply a map change without a restart, and
|
||||
// for approving a refresh that was staged because it would remove a facet.
|
||||
//
|
||||
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
|
||||
// is argument parsing and output formatting.
|
||||
|
||||
const db = () => require('../src/utils/db')
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const flag = argv[i]
|
||||
if (flag === '--servuo') args.servuo = argv[++i]
|
||||
else if (flag === '--force') args.force = true
|
||||
else if (flag === '--approve') args.approve = true
|
||||
else if (flag === '--reject') args.reject = true
|
||||
else if (flag === '--status') args.status = true
|
||||
else if (flag === '--help' || flag === '-h') args.help = true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
const USAGE = `
|
||||
Refresh the spawn atlas from a ServUO tree.
|
||||
|
||||
node scripts/importSpawnAtlas.js [options]
|
||||
|
||||
--servuo <path> Use this tree for this run instead of the configured path.
|
||||
--force Reimport even when the source files are unchanged.
|
||||
--approve Apply a refresh that was staged for removing a facet.
|
||||
--reject Keep the current atlas and dismiss the staged refresh.
|
||||
--status Report atlas and source state; change nothing.
|
||||
|
||||
With no options this imports only if the tree differs from what is loaded.
|
||||
`
|
||||
|
||||
function describe(result) {
|
||||
switch (result.status) {
|
||||
case 'skipped':
|
||||
return (
|
||||
'No ServUO path configured — nothing to import.\n' +
|
||||
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
|
||||
)
|
||||
case 'unavailable':
|
||||
return `ServUO tree unavailable: ${result.reason}\n`
|
||||
case 'unchanged':
|
||||
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
|
||||
case 'needsReview': {
|
||||
return (
|
||||
'Refresh NOT applied — it would remove ' +
|
||||
`${result.removedFacets.length} facet(s): ${result.removedFacets.join(', ')}.\n` +
|
||||
'This is what a half-copied or mid-update tree looks like, so it has been\n' +
|
||||
'staged for review. The current atlas is unchanged.\n' +
|
||||
'Apply it with --approve, or dismiss it with --reject.\n'
|
||||
)
|
||||
}
|
||||
case 'imported': {
|
||||
const c = result.counts
|
||||
const added = result.addedFacets?.length ? ` Added facets: ${result.addedFacets.join(', ')}.` : ''
|
||||
const removed = result.removedFacets?.length
|
||||
? ` Removed facets: ${result.removedFacets.join(', ')}.`
|
||||
: ''
|
||||
return (
|
||||
`Atlas imported: ${c.points} points, ${c.creatures} creatures, ` +
|
||||
`${c.pointTypes} point/type rows, ${c.regions} regions, ` +
|
||||
`${c.landmarks} landmarks, ${c.champions} champion altars.${added}${removed}\n`
|
||||
)
|
||||
}
|
||||
case 'failed':
|
||||
return `Atlas refresh failed: ${result.reason}\n`
|
||||
default:
|
||||
return `${JSON.stringify(result, null, 2)}\n`
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help) {
|
||||
process.stdout.write(USAGE)
|
||||
return
|
||||
}
|
||||
|
||||
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||
|
||||
// `--servuo` is a per-run override and deliberately does NOT persist to the
|
||||
// configured path; changing where the atlas permanently reads from is an
|
||||
// admin action, not a side effect of a one-off import.
|
||||
const override = { path: args.servuo ?? '' }
|
||||
|
||||
if (args.status) {
|
||||
process.stdout.write(`${JSON.stringify(await shardAtlas.status(override), null, 2)}\n`)
|
||||
return
|
||||
}
|
||||
if (args.reject) {
|
||||
process.stdout.write(`${JSON.stringify(await shardAtlas.rejectPending(), null, 2)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const result = args.approve
|
||||
? await shardAtlas.approvePending(override)
|
||||
: await shardAtlas.refresh({ ...override, force: Boolean(args.force) })
|
||||
|
||||
process.stdout.write(describe(result))
|
||||
if (result.status === 'failed') process.exitCode = 1
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
.catch((err) => {
|
||||
process.stderr.write(`atlas:import failed: ${err.message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => db().close())
|
||||
}
|
||||
|
||||
module.exports = { describe, parseArgs }
|
||||
261
server/scripts/routeManifest.js
Normal file
261
server/scripts/routeManifest.js
Normal file
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Route manifest generator — the machine-readable freeze of the HTTP URL surface.
|
||||
*
|
||||
* Why this exists: the router files are being carved up by business capability
|
||||
* (docs/website/API_V2_PLAN.md § Phase 2) with the explicit promise that not one
|
||||
* URL moves. "Every URL is unchanged" has to be proved by a diff, not asserted in
|
||||
* review, so this walks the *live* Express stack and writes a sorted
|
||||
* `{ method, path }` list. CI regenerates it and fails on any diff; a PR that
|
||||
* really does change a URL has to commit the new manifest, which puts the change
|
||||
* in front of a reviewer instead of letting it slip through a "mechanical" PR.
|
||||
*
|
||||
* Runtime introspection, not source parsing: it is authoritative about mounts, and
|
||||
* a route's path sits on the line *after* `router.get(`, which defeats naive
|
||||
* greps. Not swagger-output.json either — that is annotation-
|
||||
* derived (only annotated routes appear) and documents intent; this records reality.
|
||||
*
|
||||
* Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything
|
||||
* on the internal app. Three mounts in app.js are *filesystem* conditional — the SPA
|
||||
* catch-all `GET *`, the `/brand` static mount and swagger-ui's `/api/docs` static
|
||||
* assets — so including them would make the output depend on whether CI had built
|
||||
* the client. Static mounts are not API contract.
|
||||
*
|
||||
* Usage:
|
||||
* npm run routes:manifest # write server/routes.manifest.json (+ guards)
|
||||
* npm run routes:manifest -- --check # exit 1 if the committed files are stale
|
||||
*/
|
||||
|
||||
// The apps pull in models -> utils/db, which builds a mariadb pool at require time.
|
||||
// Point it at a closed port (same trick the test suite uses) so generating a
|
||||
// manifest never opens a real connection or hangs on a missing database.
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const app = require('../src/app')
|
||||
const internalApp = require('../src/internalApp')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const MANIFEST_PATH = path.join(SERVER_ROOT, 'routes.manifest.json')
|
||||
const GUARDS_PATH = path.join(SERVER_ROOT, 'routes.guards.json')
|
||||
|
||||
const MANIFEST_COMMENT =
|
||||
'Generated route inventory - the authoritative freeze of the URL surface. ' +
|
||||
'Regenerate with `npm run routes:manifest` in website/server; a domain-split PR ' +
|
||||
'must produce a zero-line diff here.'
|
||||
|
||||
const GUARDS_COMMENT =
|
||||
'Generated review aid, NOT a gated contract - per route, the middleware handler ' +
|
||||
'count and the *named* middleware collected along the mount chain. Anonymous ' +
|
||||
'handlers (e.g. the arrow returned by requireRole(...)) cannot be named, so this ' +
|
||||
'is a hint for reviewers, never a security check. Regenerate with ' +
|
||||
'`npm run routes:manifest`.'
|
||||
|
||||
// Only these prefixes are contract. Everything else the public app serves (SPA
|
||||
// shell, /uploads, /brand, swagger-ui assets) is static delivery, not API surface.
|
||||
const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
|
||||
|
||||
/**
|
||||
* Recover the literal path a router was mounted at from the layer's regexp.
|
||||
*
|
||||
* Express keeps no copy of the mount string, only the compiled regexp. For a
|
||||
* literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
|
||||
* contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both
|
||||
* gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is
|
||||
* express's marker for a router mounted at the root, which contributes nothing.
|
||||
*/
|
||||
function mountPath(layer) {
|
||||
const re = layer.regexp
|
||||
if (!re || re.fast_slash) return ''
|
||||
|
||||
let src = re.source
|
||||
.replace(/^\^/, '')
|
||||
.replace(/\\\/\?\(\?=\\\/\|\$\)$/, '') // mount tail: \/?(?=\/|$)
|
||||
.replace(/\$$/, '')
|
||||
|
||||
const keys = layer.keys || []
|
||||
let i = 0
|
||||
src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => {
|
||||
const key = keys[i++]
|
||||
return key ? `:${key.name}` : ':param'
|
||||
})
|
||||
|
||||
// Whatever is left should be a literal path with regexp-escaped separators.
|
||||
src = src.replace(/\\(.)/g, '$1')
|
||||
|
||||
if (/[()[\]?*+|^$]/.test(src)) {
|
||||
throw new Error(
|
||||
`routeManifest: could not decode mount path from regexp ${re.source} (got "${src}"). ` +
|
||||
'A non-literal mount was added — teach mountPath() about it rather than guessing.',
|
||||
)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
/** `layer.name` is 'router' for a mounted Router, and the fn name otherwise. */
|
||||
function isRouter(layer) {
|
||||
return layer.name === 'router' && layer.handle && Array.isArray(layer.handle.stack)
|
||||
}
|
||||
|
||||
/** Named middleware only — anonymous handlers have `name === ''`. */
|
||||
function namedMiddleware(handlers) {
|
||||
return handlers
|
||||
.map((h) => h && h.name)
|
||||
.filter((n) => n && n !== 'anonymous' && n !== 'bound dispatch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk an Express stack, collecting one entry per (method, path). `prefix` is the
|
||||
* path accumulated from enclosing mounts; `gates` the named router-level middleware
|
||||
* seen on the way down (a `router.use(noindex, isLoggedIn, …)` gate never appears in
|
||||
* an individual route's own stack, so it has to be carried down).
|
||||
*
|
||||
* `depth === 0` is the app's own stack — helmet, morgan, the JSON parser, the bot
|
||||
* guard. Those apply to literally every route, so recording them would bury the
|
||||
* per-route gates that actually matter under a dozen identical names.
|
||||
*/
|
||||
function walk(stack, prefix, gates, out, depth = 0) {
|
||||
const inherited = [...gates]
|
||||
|
||||
for (const layer of stack) {
|
||||
if (layer.route) {
|
||||
const routePaths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path]
|
||||
// The last handler is the controller, not a gate; everything before it is.
|
||||
const guards = layer.route.stack.slice(0, -1).map((s) => s.handle)
|
||||
for (const routePath of routePaths) {
|
||||
const full = normalize(prefix + routePath)
|
||||
for (const method of Object.keys(layer.route.methods)) {
|
||||
if (method === '_all') continue
|
||||
out.push({
|
||||
method: method.toUpperCase(),
|
||||
path: full,
|
||||
handlers: layer.route.stack.length,
|
||||
gates: [...inherited, ...namedMiddleware(guards)],
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (isRouter(layer)) {
|
||||
walk(layer.handle.stack, prefix + mountPath(layer), inherited, out, depth + 1)
|
||||
} else if (depth > 0 && layer.name && layer.name !== '<anonymous>') {
|
||||
// A bare `use()` on a mounted router — a gate applying to everything after it.
|
||||
inherited.push(layer.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapse `//` from empty mount paths and drop a trailing slash. */
|
||||
function normalize(p) {
|
||||
const collapsed = p.replace(/\/{2,}/g, '/')
|
||||
return collapsed.length > 1 ? collapsed.replace(/\/$/, '') : collapsed
|
||||
}
|
||||
|
||||
/** Sort by path, then method — stable and diff-friendly. */
|
||||
function bySurface(a, b) {
|
||||
if (a.path !== b.path) return a.path < b.path ? -1 : 1
|
||||
if (a.method !== b.method) return a.method < b.method ? -1 : 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function dedupe(entries) {
|
||||
const seen = new Map()
|
||||
for (const e of entries) {
|
||||
const key = `${e.method} ${e.path}`
|
||||
if (!seen.has(key)) seen.set(key, e)
|
||||
}
|
||||
return [...seen.values()]
|
||||
}
|
||||
|
||||
/** Collect the full route table for both listeners. */
|
||||
function collect() {
|
||||
const publicRoutes = []
|
||||
walk(app._router.stack, '', [], publicRoutes)
|
||||
|
||||
const internalRoutes = []
|
||||
walk(internalApp._router.stack, '', [], internalRoutes)
|
||||
|
||||
return {
|
||||
public: dedupe(
|
||||
publicRoutes.filter((r) => PUBLIC_PREFIXES.some((p) => r.path.startsWith(p))),
|
||||
).sort(bySurface),
|
||||
internal: dedupe(internalRoutes).sort(bySurface),
|
||||
}
|
||||
}
|
||||
|
||||
/** The gated contract: method + path only, which is exactly what must not change. */
|
||||
function buildManifest(collected) {
|
||||
const strip = (rs) => rs.map((r) => ({ method: r.method, path: r.path }))
|
||||
return {
|
||||
$comment: MANIFEST_COMMENT,
|
||||
public: strip(collected.public),
|
||||
internal: strip(collected.internal),
|
||||
}
|
||||
}
|
||||
|
||||
/** The ungated review aid: same routes, plus handler count and named gates. */
|
||||
function buildGuards(collected) {
|
||||
const shape = (rs) =>
|
||||
rs.map((r) => ({
|
||||
method: r.method,
|
||||
path: r.path,
|
||||
handlers: r.handlers,
|
||||
gates: [...new Set(r.gates)],
|
||||
}))
|
||||
return {
|
||||
$comment: GUARDS_COMMENT,
|
||||
public: shape(collected.public),
|
||||
internal: shape(collected.internal),
|
||||
}
|
||||
}
|
||||
|
||||
// Always LF + a trailing newline so the file is byte-identical on Windows and CI.
|
||||
function serialize(obj) {
|
||||
return `${JSON.stringify(obj, null, 2)}\n`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const collected = collect()
|
||||
const files = [
|
||||
[MANIFEST_PATH, serialize(buildManifest(collected))],
|
||||
[GUARDS_PATH, serialize(buildGuards(collected))],
|
||||
]
|
||||
|
||||
let stale = 0
|
||||
for (const [file, contents] of files) {
|
||||
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') : null
|
||||
if (check) {
|
||||
if (current !== contents) {
|
||||
process.stderr.write(`stale: ${path.relative(SERVER_ROOT, file)}\n`)
|
||||
stale += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
fs.writeFileSync(file, contents)
|
||||
}
|
||||
|
||||
const total = collected.public.length + collected.internal.length
|
||||
if (check) {
|
||||
if (stale) {
|
||||
process.stderr.write('Run `npm run routes:manifest` and commit the result.\n')
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
process.stdout.write(`route manifest up to date (${total} routes)\n`)
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`wrote routes.manifest.json (${collected.public.length} public + ${collected.internal.length} internal)\n`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
// The mariadb pool keeps the loop alive even pointed at a dead port.
|
||||
db.close().finally(() => process.exit(process.exitCode || 0))
|
||||
}
|
||||
|
||||
module.exports = { collect, buildManifest, buildGuards, serialize, mountPath }
|
||||
@@ -11,7 +11,10 @@ const swaggerUi = require('swagger-ui-express')
|
||||
|
||||
const apiRouter = require('./router/api.router')
|
||||
const wellKnown = require('./router/wellKnown.controller')
|
||||
const cspReport = require('./router/cspReport.controller')
|
||||
const brand = require('./config/brand')
|
||||
const csp = require('./config/csp')
|
||||
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
@@ -37,41 +40,31 @@ app.use(trustProxyDebug)
|
||||
app.use(botScore.guard)
|
||||
|
||||
// Security headers, including a Content-Security-Policy tuned for the built React
|
||||
// SPA. Notes on each non-'self' allowance:
|
||||
// • style-src 'unsafe-inline' — React renders pervasive inline `style={{…}}`
|
||||
// attributes, and CSP style *attributes* cannot be nonce'd; this is required.
|
||||
// Also whitelists the Google Fonts stylesheet host.
|
||||
// • font-src — Google Fonts (Cinzel) serves the font files from gstatic.
|
||||
// • img-src https:/data: — uploaded images are same-origin, but wiki/news bodies
|
||||
// (sanitizeHtml allows <img> over http/https) and BRAND_* logo/hero/favicon may
|
||||
// point at external https images. http images are blocked by mixed-content on
|
||||
// the https site anyway.
|
||||
// • connect-src 'self' — the REST API and SSE streams are same-origin.
|
||||
// • upgrade-insecure-requests is intentionally dropped: TLS is terminated at the
|
||||
// proxy, there are no mixed-content subresources to upgrade, and leaving it on
|
||||
// breaks a local `npm start` served over plain http.
|
||||
// The interactive API docs at /api/docs get their own looser policy below.
|
||||
// SPA. The policies themselves (and the reasoning behind every non-'self' allowance)
|
||||
// live in config/csp.js. The interactive API docs at /api/docs get their own looser
|
||||
// policy below.
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'"],
|
||||
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
'font-src': ["'self'", 'https://fonts.gstatic.com'],
|
||||
'img-src': ["'self'", 'data:', 'https:'],
|
||||
'connect-src': ["'self'"],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'object-src': ["'none'"],
|
||||
'base-uri': ["'self'"],
|
||||
'upgrade-insecure-requests': null,
|
||||
},
|
||||
},
|
||||
contentSecurityPolicy: { useDefaults: true, directives: csp.enforced },
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}),
|
||||
)
|
||||
|
||||
// The tightened policy rides alongside on Content-Security-Policy-Report-Only for one
|
||||
// release, then replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1).
|
||||
// Both headers are served at once on purpose: the live policy keeps protecting users
|
||||
// while anything the tightened version would have broken shows up as a report at
|
||||
// /api/csp-report instead of as a broken page. Reports are same-origin — they
|
||||
// describe attacks on this site and are not handed to a third party.
|
||||
app.use(csp.reportingEndpoints)
|
||||
app.use(
|
||||
helmet.contentSecurityPolicy({
|
||||
useDefaults: true,
|
||||
reportOnly: true,
|
||||
directives: csp.reportOnly,
|
||||
}),
|
||||
)
|
||||
|
||||
// CORS only when a separate client origin is configured (local Vite dev). In
|
||||
// production the SPA is same-origin, so no CORS is needed.
|
||||
if (process.env.CLIENT_ORIGIN) {
|
||||
@@ -179,6 +172,11 @@ app.get(
|
||||
/* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */
|
||||
(req, res) => res.json({ status: 'ok' }),
|
||||
)
|
||||
// CSP violation sink. Mounted here, ahead of the /api 404, and outside /api/v1: it is
|
||||
// not part of the versioned client contract — it exists for the browser, which learns
|
||||
// the path from the policy header, never from a client build.
|
||||
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
|
||||
|
||||
app.use('/api', apiRouter)
|
||||
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const crypto = require('crypto')
|
||||
|
||||
const token = require('./token')
|
||||
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
|
||||
const trustedDevices = require('../model/trustedDevices/trustedDevices.model')
|
||||
const users = require('../model/users/users.model')
|
||||
const log = require('../utils/logger')('session')
|
||||
|
||||
@@ -198,6 +199,63 @@ function sessionMeta(req) {
|
||||
return { ip, userAgent, deviceHash }
|
||||
}
|
||||
|
||||
// ── Trusted devices (MFA "Trust this device") ──────────────────────────────
|
||||
// A trusted device lets a login SKIP the TOTP step (never the password). The
|
||||
// opaque trust token lives client-side (rg_trust cookie on web, X-Trust-Token /
|
||||
// EncryptedSharedPreferences on native); only its sha256 hash is stored, so — like
|
||||
// the mobile refresh token — the server side is revocable and never holds the raw
|
||||
// secret. These functions mint/hash/resolve; the controller sets the cookie and
|
||||
// the trustedDevices model persists the row. sha256 (not bcrypt): the token is a
|
||||
// 256-bit random value looked up BY its hash via a UNIQUE index.
|
||||
|
||||
const TRUSTED_DEVICE_TTL_DAYS = Number(process.env.TRUSTED_DEVICE_TTL_DAYS) || 30
|
||||
|
||||
// Hash a raw trust token to the value stored in the DB. Separate name from
|
||||
// hashRefreshToken so intent is explicit at call sites, though the algorithm is
|
||||
// the same deterministic sha256.
|
||||
function hashTrustToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Mint a fresh opaque trust token + its hash + expiry. `meta` (from sessionMeta)
|
||||
// supplies the best-effort device fingerprint stored for display. `now` injectable
|
||||
// for tests. Does NOT touch cookies or the DB.
|
||||
function mintTrustToken(meta = {}, now = Date.now()) {
|
||||
const trustToken = crypto.randomBytes(32).toString('base64url') // 256 bits, opaque
|
||||
const expiresAt = new Date(now + TRUSTED_DEVICE_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
trustToken,
|
||||
trustHash: hashTrustToken(trustToken),
|
||||
deviceHash: meta.deviceHash || null,
|
||||
userAgent: meta.userAgent || null,
|
||||
expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the trust token on an incoming request to its still-valid DB row (or
|
||||
// null). The caller MUST confirm row.user_id matches the user who just passed the
|
||||
// password step before honoring it — a trust token is scoped to the account that
|
||||
// created it. Never throws on a DB hiccup here; the caller falls back to TOTP.
|
||||
async function resolveTrustedDevice(req) {
|
||||
const raw = token.extractTrustToken(req)
|
||||
if (!raw) return null
|
||||
return trustedDevices.findValidByHash(hashTrustToken(raw))
|
||||
}
|
||||
|
||||
// Stamp a trusted device as used (called when its trust was honored to skip TOTP).
|
||||
async function honorTrustedDevice(id) {
|
||||
if (!id) return false
|
||||
await trustedDevices.touchLastUsed(id)
|
||||
return true
|
||||
}
|
||||
|
||||
// True if the user already holds the maximum number of trusted devices. Callers
|
||||
// refuse a new trust (signaling the client to revoke one first) rather than
|
||||
// pruning silently. See docs/website/TRUSTED_DEVICES_MFA.md §5.
|
||||
async function trustDeviceCapReached(userId) {
|
||||
return trustedDevices.isAtCap(userId)
|
||||
}
|
||||
|
||||
// ── Revocation / invalidation ──────────────────────────────────────────────
|
||||
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
|
||||
// two server-side stores these functions write:
|
||||
@@ -264,4 +322,10 @@ module.exports = {
|
||||
refreshMobileSession,
|
||||
validateBearerToken,
|
||||
hashRefreshToken,
|
||||
// Trusted devices (MFA "Trust this device").
|
||||
hashTrustToken,
|
||||
mintTrustToken,
|
||||
resolveTrustedDevice,
|
||||
honorTrustedDevice,
|
||||
trustDeviceCapReached,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ const log = require('../utils/logger')('auth')
|
||||
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
|
||||
const COOKIE_NAME = process.env.COOKIE_NAME || 'rg_token'
|
||||
// Separate cookie carrying the opaque trusted-device token (MFA "Trust this
|
||||
// device"). Distinct from the session cookie so it deliberately OUTLIVES logout —
|
||||
// a trusted browser skips the TOTP step on its next login (never the password).
|
||||
const TRUST_COOKIE_NAME = process.env.TRUST_COOKIE_NAME || 'rg_trust'
|
||||
const TRUSTED_DEVICE_TTL_DAYS = Number(process.env.TRUSTED_DEVICE_TTL_DAYS) || 30
|
||||
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
|
||||
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
|
||||
|
||||
@@ -128,8 +133,36 @@ function extractToken(req) {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Trusted-device cookie (MFA "Trust this device") ────────────────────────
|
||||
// Rough max-age (ms) for the trust cookie: TRUSTED_DEVICE_TTL_DAYS days.
|
||||
function trustCookieMaxAge() {
|
||||
return TRUSTED_DEVICE_TTL_DAYS * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// Same hardening as the session cookie (httpOnly, sameSite=Lax, per-request
|
||||
// Secure), but its own name and a 30-day max-age. httpOnly keeps it out of JS.
|
||||
function setTrustCookie(req, res, trustToken) {
|
||||
res.cookie(TRUST_COOKIE_NAME, trustToken, { ...cookieOptions(req), maxAge: trustCookieMaxAge() })
|
||||
}
|
||||
|
||||
function clearTrustCookie(req, res) {
|
||||
res.clearCookie(TRUST_COOKIE_NAME, cookieOptions(req))
|
||||
}
|
||||
|
||||
// Read the opaque trust token from its cookie (web) or the X-Trust-Token header
|
||||
// (native clients, which store it in EncryptedSharedPreferences rather than a
|
||||
// cookie jar). Returns null when absent.
|
||||
function extractTrustToken(req) {
|
||||
if (req.cookies && req.cookies[TRUST_COOKIE_NAME]) return req.cookies[TRUST_COOKIE_NAME]
|
||||
const header = req.headers && req.headers['x-trust-token']
|
||||
if (header && String(header).trim()) return String(header).trim()
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
TRUST_COOKIE_NAME,
|
||||
TRUSTED_DEVICE_TTL_DAYS,
|
||||
JWT_EXPIRES_IN,
|
||||
resolveJwtSecret,
|
||||
signToken,
|
||||
@@ -144,4 +177,8 @@ module.exports = {
|
||||
setAuthCookie,
|
||||
clearAuthCookie,
|
||||
extractToken,
|
||||
trustCookieMaxAge,
|
||||
setTrustCookie,
|
||||
clearTrustCookie,
|
||||
extractTrustToken,
|
||||
}
|
||||
|
||||
101
server/src/config/csp.js
Normal file
101
server/src/config/csp.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// ── Content-Security-Policy ────────────────────────────────────────────────
|
||||
//
|
||||
// Two policies ship at once, on two different headers:
|
||||
//
|
||||
// Content-Security-Policy → `enforced` (today's policy, unchanged)
|
||||
// Content-Security-Policy-Report-Only → `reportOnly` (the target, + a report sink)
|
||||
//
|
||||
// Report-only first, one release of observation, then the two collapse into one
|
||||
// enforced policy (docs/website/API_V2_PLAN.md § Phase 1). Shipping the tightened
|
||||
// policy straight to `Content-Security-Policy` would mean discovering any legitimate
|
||||
// use we forgot as a broken page in production; shipping it *alongside* the current
|
||||
// one means a violation report instead, with the live policy still protecting users
|
||||
// the whole time.
|
||||
//
|
||||
// Notes on each non-'self' allowance in the base policy:
|
||||
// • style-src 'unsafe-inline' — React renders pervasive inline `style={{…}}`
|
||||
// attributes, and CSP style *attributes* cannot be nonce'd; this is required.
|
||||
// It permits inline styling, not script execution. Also whitelists the Google
|
||||
// Fonts stylesheet host.
|
||||
// • font-src — Google Fonts (Cinzel) serves the font files from gstatic.
|
||||
// • img-src https:/data: — uploaded images are same-origin, but wiki/news bodies
|
||||
// (sanitizeHtml allows <img> over http/https) and BRAND_* logo/hero/favicon may
|
||||
// point at external https images. http images are blocked by mixed-content on
|
||||
// the https site anyway.
|
||||
// • connect-src 'self' — the REST API and SSE streams are same-origin. This is the
|
||||
// exfiltration channel; do not widen it unless the API genuinely becomes
|
||||
// cross-origin (which would also reopen the auth-merge question — see the plan).
|
||||
// • script-src 'self' with no 'unsafe-inline'/'unsafe-eval' is the primary defense.
|
||||
// Vite is configured with `modulePreload: { polyfill: false }` (client/vite.config.js)
|
||||
// precisely so the build emits no inline bootstrap script for this to trip on.
|
||||
// • upgrade-insecure-requests is intentionally dropped: TLS is terminated at the
|
||||
// proxy, there are no mixed-content subresources to upgrade, and leaving it on
|
||||
// breaks a local `npm start` served over plain http.
|
||||
//
|
||||
// The interactive API docs at /api/docs get their own looser policy (swagger-ui
|
||||
// injects an inline bootstrap script); that carve-out lives in app.js and stays
|
||||
// scoped to the one route.
|
||||
|
||||
// Where violation reports are POSTed, and the Reporting-API group name that points
|
||||
// at it. Same-origin on purpose — reports describe attacks against this site and
|
||||
// must not be shipped to a third party.
|
||||
const REPORT_PATH = '/api/csp-report'
|
||||
const REPORT_GROUP = 'csp-endpoint'
|
||||
|
||||
// The policy in force today. Behaviourally unchanged by this phase — it is the safety
|
||||
// net while the tightened twin is only being observed.
|
||||
const enforced = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'"],
|
||||
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
'font-src': ["'self'", 'https://fonts.gstatic.com'],
|
||||
'img-src': ["'self'", 'data:', 'https:'],
|
||||
'connect-src': ["'self'"],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'object-src': ["'none'"],
|
||||
'base-uri': ["'self'"],
|
||||
// Blocks an injected `<form action="https://evil">` from POSTing credentials
|
||||
// off-origin — an exfil path connect-src does not cover. Already emitted today via
|
||||
// helmet's `useDefaults`, and pinned here on purpose: a security directive should
|
||||
// not depend on a third-party library's default surviving its next major version.
|
||||
// Adding it changes the header's *contents* not at all.
|
||||
'form-action': ["'self'"],
|
||||
'upgrade-insecure-requests': null,
|
||||
}
|
||||
|
||||
// The one directive this phase actually changes, and so the only thing a report can
|
||||
// legitimately be about:
|
||||
//
|
||||
// • frame-ancestors 'self' → 'none'. Nothing legitimately frames the site, and
|
||||
// 'self' only means anything if some same-origin page frames another; none does.
|
||||
// Worth a soak rather than a straight flip precisely because a violation report
|
||||
// is how we would find out that something *does* — the report comes from the
|
||||
// browser of whoever framed us, which is information we cannot get any other way.
|
||||
//
|
||||
// Derived from `enforced` rather than written out again, so the two policies cannot
|
||||
// silently drift apart and this object stays a readable diff of the change.
|
||||
const tightened = {
|
||||
...enforced,
|
||||
'frame-ancestors': ["'none'"],
|
||||
}
|
||||
|
||||
const reportOnly = {
|
||||
...tightened,
|
||||
// Both mechanisms, deliberately: `report-to` is the current Reporting API (Chrome,
|
||||
// needs the Reporting-Endpoints header below), `report-uri` is deprecated but is
|
||||
// still the only one Firefox and Safari implement. Browsers that support both send
|
||||
// one report, not two.
|
||||
'report-to': [REPORT_GROUP],
|
||||
'report-uri': [REPORT_PATH],
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the Reporting-API group that `report-to` refers to. Without this header the
|
||||
* `report-to` directive is inert, and only the `report-uri` fallback would fire.
|
||||
*/
|
||||
function reportingEndpoints(req, res, next) {
|
||||
res.setHeader('Reporting-Endpoints', `${REPORT_GROUP}="${REPORT_PATH}"`)
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = { enforced, tightened, reportOnly, reportingEndpoints, REPORT_PATH, REPORT_GROUP }
|
||||
@@ -118,6 +118,31 @@ const passwordResetConfirmLimiter = makeLimiter({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// The player-vendor market search. The first genuinely expensive PUBLIC endpoint
|
||||
// on the site: every call is a LIKE scan plus a COUNT over the listings table,
|
||||
// which on a large shard is the biggest table there is, and it is anonymous by
|
||||
// default. Generous for a human browsing shops (a typed search is debounced to
|
||||
// one request, and paging is a click), tight enough that it cannot be used as a
|
||||
// cheap way to load the database.
|
||||
const marketLimiter = makeLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
label: 'market',
|
||||
message: 'Too many searches. Please slow down.',
|
||||
})
|
||||
|
||||
// CSP violation reports. Unauthenticated by necessity (browsers send them with no
|
||||
// session), and every accepted report writes a log line — so an attacker who can get
|
||||
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
|
||||
// enough for the real case: a genuinely broken directive fires a handful of times per
|
||||
// page load, and browsers already de-duplicate identical violations per document.
|
||||
const cspReportLimiter = makeLimiter({
|
||||
windowMs: 5 * 60 * 1000,
|
||||
max: 60,
|
||||
label: 'csp-report',
|
||||
message: 'Too many reports.',
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
@@ -129,4 +154,6 @@ module.exports = {
|
||||
mobileSsoExchangeLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
marketLimiter,
|
||||
cspReportLimiter,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
const { deriveExcerpt } = require('../../utils/sanitizeHtml')
|
||||
|
||||
// Sidecar town-crier caps, mirrored from the admin route validation
|
||||
// (admin.routes.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||
// We pre-truncate to these so a published post never bounces with towncrier.error.
|
||||
const MAX_LINES = 8
|
||||
const MAX_LINE_LEN = 200
|
||||
|
||||
@@ -37,6 +37,20 @@ async function completeSession(sessionId, userId) {
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Record that the user asked to trust this device on the Custom Tab TOTP form.
|
||||
// Guarded on status + expiry for the same reason completeSession is: a replayed
|
||||
// TOTP post must not re-arm a session that has already been consumed. Stores a
|
||||
// boolean only — the trust token is minted at /exchange and never lands here.
|
||||
async function setTrustDevice(sessionId) {
|
||||
const res = await query(
|
||||
`UPDATE mobile_auth_sessions
|
||||
SET trust_device = 1
|
||||
WHERE session_id = ? AND status = 'pending' AND expires_at > NOW()`,
|
||||
[sessionId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Mark a session `consumed` after a successful token exchange (stamps used_at).
|
||||
async function consumeSession(sessionId) {
|
||||
const res = await query(
|
||||
@@ -93,6 +107,7 @@ module.exports = {
|
||||
insertSession,
|
||||
getSession,
|
||||
completeSession,
|
||||
setTrustDevice,
|
||||
consumeSession,
|
||||
insertCode,
|
||||
findValidCode,
|
||||
|
||||
@@ -79,6 +79,14 @@ async function consumeCode(rawCode) {
|
||||
return changed > 0
|
||||
}
|
||||
|
||||
// Flag that the user ticked "trust this device" on the Custom Tab TOTP form. The
|
||||
// exchange step reads this to decide whether to mint the app's own trust token.
|
||||
// Returns true iff the session was still eligible to be flagged.
|
||||
async function markTrustRequested(sessionId) {
|
||||
if (!sessionId) return false
|
||||
return (await db.setTrustDevice(sessionId)) > 0
|
||||
}
|
||||
|
||||
// Mark a session fully consumed after a successful exchange.
|
||||
async function finishSession(sessionId) {
|
||||
return db.consumeSession(sessionId)
|
||||
@@ -97,6 +105,7 @@ module.exports = {
|
||||
issueAuthCode,
|
||||
findRedeemableCode,
|
||||
consumeCode,
|
||||
markTrustRequested,
|
||||
finishSession,
|
||||
pruneExpired,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function reshapeWindows(rows) {
|
||||
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
|
||||
function windowValue(row, key) {
|
||||
if (!row) return 0
|
||||
const col = { '24h': row.d1, '7d': row.d7 }[key] ?? row.d30
|
||||
const col = { '24h': row.d1, '7d': row.d7, '30d': row.d30 }[key]
|
||||
return Number(col) || 0
|
||||
}
|
||||
|
||||
|
||||
63
server/src/model/recoveryCodes/recoveryCodes.db.js
Normal file
63
server/src/model/recoveryCodes/recoveryCodes.db.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for the recovery_codes table. Each row is one bcrypt-hashed, single-use
|
||||
// backup code. The raw codes are shown to the user exactly once at generation and
|
||||
// never stored in the clear.
|
||||
|
||||
// Bulk-insert freshly generated code hashes for a user. `hashes` is an array of
|
||||
// bcrypt strings. One multi-row INSERT keeps generation atomic-ish and cheap.
|
||||
async function insertMany(userId, hashes) {
|
||||
if (!hashes || hashes.length === 0) return 0
|
||||
const values = hashes.map(() => '(?, ?)').join(', ')
|
||||
const params = []
|
||||
for (const h of hashes) params.push(userId, h)
|
||||
const res = await query(
|
||||
`INSERT INTO recovery_codes (user_id, code_hash) VALUES ${values}`,
|
||||
params,
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// All not-yet-used codes for a user (hashes included — this is the verify path,
|
||||
// server-side only). Ordered by id so verification is deterministic.
|
||||
async function listUnusedForUser(userId) {
|
||||
return query(
|
||||
'SELECT id, code_hash FROM recovery_codes WHERE user_id = ? AND used_at IS NULL ORDER BY id',
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
// Count a user's remaining (unused) codes — for the status endpoint (never the
|
||||
// codes themselves).
|
||||
async function countUnusedForUser(userId) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n FROM recovery_codes WHERE user_id = ? AND used_at IS NULL',
|
||||
[userId],
|
||||
)
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
// Mark one code row used (single-use). Guarded on used_at IS NULL so a race can
|
||||
// only consume it once. Returns rows changed.
|
||||
async function markUsed(id) {
|
||||
const res = await query(
|
||||
'UPDATE recovery_codes SET used_at = NOW() WHERE id = ? AND used_at IS NULL',
|
||||
[id],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Delete every code for a user. Used both when regenerating (replace the set) and
|
||||
// on TOTP disable / password change/reset. Returns rows removed.
|
||||
async function deleteAllForUser(userId) {
|
||||
const res = await query('DELETE FROM recovery_codes WHERE user_id = ?', [userId])
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insertMany,
|
||||
listUnusedForUser,
|
||||
countUnusedForUser,
|
||||
markUsed,
|
||||
deleteAllForUser,
|
||||
}
|
||||
86
server/src/model/recoveryCodes/recoveryCodes.model.js
Normal file
86
server/src/model/recoveryCodes/recoveryCodes.model.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Recovery (backup) code store. Logic layer over recoveryCodes.db, doing the
|
||||
// bcrypt hashing itself — the same pattern as users.model hashing passwords (a
|
||||
// recovery code is a human-typed, lower-entropy fallback credential, so bcrypt,
|
||||
// not sha256; see docs/website/TRUSTED_DEVICES_MFA.md §3). Codes are generated in
|
||||
// batches, shown to the user once, and consumed single-use at login.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const bcrypt = require('bcryptjs')
|
||||
|
||||
const db = require('./recoveryCodes.db')
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
const CODE_COUNT = Number(process.env.RECOVERY_CODE_COUNT) || 10
|
||||
// 10 chars from a 32-symbol alphabet ≈ 50 bits of entropy per code. Crockford-ish
|
||||
// base32 minus visually ambiguous glyphs (no I, L, O, U) to keep hand-entry clean.
|
||||
const ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ'
|
||||
const CODE_LEN = 10
|
||||
|
||||
// Canonical form used for hashing + comparison: uppercase, alphanumerics only.
|
||||
// Display adds a dash for readability; input is normalized back to this before
|
||||
// bcrypt.compare so 'abcde-fghij', 'ABCDEFGHIJ', etc. all verify.
|
||||
function normalize(code) {
|
||||
return String(code || '').toUpperCase().replace(/[^0-9A-Z]/g, '')
|
||||
}
|
||||
|
||||
// One random code in canonical form (no separator).
|
||||
function generateCode() {
|
||||
const bytes = crypto.randomBytes(CODE_LEN)
|
||||
let out = ''
|
||||
for (let i = 0; i < CODE_LEN; i++) out += ALPHABET[bytes[i] % ALPHABET.length]
|
||||
return out
|
||||
}
|
||||
|
||||
// Present a canonical code to the user with a mid-string dash (display only).
|
||||
function formatForDisplay(code) {
|
||||
const mid = Math.floor(code.length / 2)
|
||||
return `${code.slice(0, mid)}-${code.slice(mid)}`
|
||||
}
|
||||
|
||||
// Generate a fresh batch, REPLACING any existing codes for the user (regeneration
|
||||
// invalidates the old set). Returns the plaintext codes for one-time display — the
|
||||
// only time they exist outside the user's hands.
|
||||
async function generateForUser(userId, count = CODE_COUNT) {
|
||||
const plain = Array.from({ length: count }, generateCode)
|
||||
const hashes = await Promise.all(plain.map((c) => bcrypt.hash(c, SALT_ROUNDS)))
|
||||
await db.deleteAllForUser(userId)
|
||||
await db.insertMany(userId, hashes)
|
||||
return plain.map(formatForDisplay)
|
||||
}
|
||||
|
||||
// Verify + consume a recovery code (single-use). Normalizes input, bcrypt-compares
|
||||
// against the user's unused codes, and marks the first match used. Returns true iff
|
||||
// a code was consumed. Timing is dominated by bcrypt regardless of match position.
|
||||
async function consumeForUser(userId, rawCode) {
|
||||
const candidate = normalize(rawCode)
|
||||
if (!candidate) return false
|
||||
const rows = await db.listUnusedForUser(userId)
|
||||
for (const row of rows) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await bcrypt.compare(candidate, row.code_hash)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const changed = await db.markUsed(row.id)
|
||||
return changed > 0 // lost the race to consume this exact code → treat as fail
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Remaining (unused) code count — for the status endpoint. Never returns codes.
|
||||
async function remainingForUser(userId) {
|
||||
return db.countUnusedForUser(userId)
|
||||
}
|
||||
|
||||
// Clear every code for a user (TOTP disable / password change/reset).
|
||||
async function clearForUser(userId) {
|
||||
return db.deleteAllForUser(userId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CODE_COUNT,
|
||||
normalize,
|
||||
generateForUser,
|
||||
consumeForUser,
|
||||
remainingForUser,
|
||||
clearForUser,
|
||||
}
|
||||
@@ -70,6 +70,24 @@ async function isMobileAppLinksEnabled() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This instance's name, resolved exactly as `getPublic().brand.name` resolves it —
|
||||
* the admin-editable site title wins over BRAND_NAME. Anything that has to *speak*
|
||||
* the instance's name outside the settings payload must use this rather than
|
||||
* `brand.name`, or an install that set only the site title gets two different names
|
||||
* on two different pages.
|
||||
*
|
||||
* Never throws: a name is always better than an error, so a DB fault falls back to
|
||||
* the env value.
|
||||
*/
|
||||
async function getInstanceName() {
|
||||
try {
|
||||
return (await settingsDb.get('site_title')) || brand.name
|
||||
} catch {
|
||||
return brand.name
|
||||
}
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
return settingsDb.get(key)
|
||||
}
|
||||
@@ -154,6 +172,7 @@ module.exports = {
|
||||
setMany,
|
||||
getAll,
|
||||
getPublic,
|
||||
getInstanceName,
|
||||
PUBLIC_KEYS,
|
||||
REGISTRATION_KEY,
|
||||
REGISTRATION_MODES,
|
||||
|
||||
390
server/src/model/shardAtlas/shardAtlas.db.js
Normal file
390
server/src/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,390 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||
// empties and refills all six inside one transaction, and nothing else in the
|
||||
// codebase writes to them. There are no foreign keys, consistent with every
|
||||
// other shard_* table.
|
||||
|
||||
const BATCH = 500
|
||||
|
||||
const ATLAS_TABLES = [
|
||||
'shard_spawn_point_types',
|
||||
'shard_spawn_points',
|
||||
'shard_spawn_creatures',
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
]
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire atlas in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a failed reload must leave the previous atlas
|
||||
* intact rather than a half-loaded world, since a partially-imported atlas is
|
||||
* indistinguishable from a real one to anyone reading it.
|
||||
*
|
||||
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||
* commits, which would defeat exactly that guarantee. At ~7k rows the cost of
|
||||
* `DELETE` is irrelevant.
|
||||
*/
|
||||
async function replaceAtlas(atlas, art = {}) {
|
||||
const conn = await pool.getConnection()
|
||||
const counts = {}
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`)
|
||||
|
||||
counts.creatures = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
|
||||
atlas.creatures.map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.total ?? 0,
|
||||
c.points ?? 0,
|
||||
JSON.stringify(c.facets ?? {}),
|
||||
art[c.slug] ?? null,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.regions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
|
||||
atlas.regions.map((r) => [
|
||||
r.facet,
|
||||
r.name,
|
||||
r.type || null,
|
||||
r.priority ?? 0,
|
||||
r.parent || null,
|
||||
JSON.stringify(r.rects ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
counts.landmarks = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
|
||||
atlas.landmarks.map((l) => [
|
||||
l.facet,
|
||||
l.name,
|
||||
l.group || null,
|
||||
l.x ?? 0,
|
||||
l.y ?? 0,
|
||||
l.z ?? 0,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.champions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_champion_spawns ' +
|
||||
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.champions.map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.group || null,
|
||||
c.type || null,
|
||||
c.randomType ? 1 : 0,
|
||||
c.facet,
|
||||
c.x ?? 0,
|
||||
c.y ?? 0,
|
||||
c.z ?? 0,
|
||||
c.radius ?? 0,
|
||||
c.label || null,
|
||||
]),
|
||||
)
|
||||
|
||||
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
||||
// join rows need to know them and `conn.batch()` reports no usable insertId
|
||||
// for a multi-row insert. Safe because this transaction just emptied the
|
||||
// table and nothing else writes to it.
|
||||
counts.points = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_points ' +
|
||||
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.points.map((p, i) => [
|
||||
i + 1,
|
||||
p.facet,
|
||||
p.name,
|
||||
p.x,
|
||||
p.y,
|
||||
p.width ?? 0,
|
||||
p.height ?? 0,
|
||||
p.range ?? 0,
|
||||
p.maxCount ?? 0,
|
||||
p.minDelay ?? 0,
|
||||
p.maxDelay ?? 0,
|
||||
p.todStart ?? 0,
|
||||
p.todEnd ?? 0,
|
||||
p.todMode ?? 0,
|
||||
p.region,
|
||||
p.landmark,
|
||||
p.label || 'Wilderness',
|
||||
]),
|
||||
)
|
||||
|
||||
counts.pointTypes = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
|
||||
atlas.pointTypes,
|
||||
)
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...atlas.meta, importedCounts: counts })],
|
||||
)
|
||||
|
||||
// A completed import answers whatever was pending.
|
||||
await conn.query('DELETE FROM shard_atlas_pending')
|
||||
|
||||
await conn.commit()
|
||||
return counts
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
/** Facet names currently loaded, used to detect a facet disappearing. */
|
||||
async function getFacets() {
|
||||
const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet')
|
||||
return rows.map((row) => row.facet)
|
||||
}
|
||||
|
||||
// ── Pending review ─────────────────────────────────────────────────────────
|
||||
|
||||
async function getPending() {
|
||||
const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at }
|
||||
}
|
||||
|
||||
async function setPending(payload, status = 'pending') {
|
||||
return query(
|
||||
'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' +
|
||||
'detected_at = CURRENT_TIMESTAMP',
|
||||
[status, JSON.stringify(payload)],
|
||||
)
|
||||
}
|
||||
|
||||
async function clearPending() {
|
||||
return query('DELETE FROM shard_atlas_pending')
|
||||
}
|
||||
|
||||
// ── Reads (the public /atlas surface) ──────────────────────────────────────
|
||||
//
|
||||
// Every read here is a plain indexed query over ~7k rows and is served entirely
|
||||
// from MariaDB: the atlas is static shard content, so nothing on this path
|
||||
// touches the sidecar and nothing degrades when the shard is down.
|
||||
//
|
||||
// A facet filter is expressed as EXISTS over the points, never as a JSON path
|
||||
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
|
||||
// by facet name, and matching a key means either concatenating the name into a
|
||||
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
|
||||
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
|
||||
// uses the indexes that already exist.
|
||||
const CREATURE_FACET_EXISTS = `EXISTS (
|
||||
SELECT 1 FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = c.slug AND p.facet = ?
|
||||
)`
|
||||
|
||||
// Build the WHERE for a creature search. `q` is a substring match on the display
|
||||
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
|
||||
// minimum token length to break a search for "orc".
|
||||
function creatureWhere({ q, facet }) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('c.name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
if (facet) {
|
||||
where.push(CREATURE_FACET_EXISTS)
|
||||
params.push(facet)
|
||||
}
|
||||
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
async function countCreatures({ q = '', facet = '' } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
return query(
|
||||
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
|
||||
FROM shard_spawn_creatures c
|
||||
${sql}
|
||||
ORDER BY c.total DESC, c.name ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
async function getCreature(slug) {
|
||||
const rows = await query(
|
||||
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
|
||||
[slug],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a creature spawns, grouped by resolved place.
|
||||
*
|
||||
* This is the answer the atlas exists to give — "lizardman → Shrines,
|
||||
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
|
||||
* point rows in Node.
|
||||
*/
|
||||
function listCreaturePlaces(slug, { facet = '' } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
GROUP BY p.facet, p.label
|
||||
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** The individual spawners for a creature, newest-largest first. Bounded. */
|
||||
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
params.push(limit)
|
||||
return query(
|
||||
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
|
||||
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
|
||||
p.region, p.landmark, p.label, t.max_count
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every other creature sharing a spawner with this one. */
|
||||
function listCreatureCompanions(slug, { limit = 24 } = {}) {
|
||||
return query(
|
||||
`SELECT o.slug, c.name, COUNT(*) AS shared
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
|
||||
JOIN shard_spawn_creatures c ON c.slug = o.slug
|
||||
WHERE t.slug = ?
|
||||
GROUP BY o.slug, c.name
|
||||
ORDER BY shared DESC, c.name ASC
|
||||
LIMIT ?`,
|
||||
[slug, limit],
|
||||
)
|
||||
}
|
||||
|
||||
function listRegions({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, type, priority, parent, rects
|
||||
FROM shard_regions
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listLandmarks({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('(name LIKE ? OR grp LIKE ?)')
|
||||
params.push(`%${q}%`, `%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, grp, x, y, z
|
||||
FROM shard_landmarks
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, grp ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listChampions({ facet = '' } = {}) {
|
||||
const params = []
|
||||
let where = ''
|
||||
if (facet) {
|
||||
where = 'WHERE facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
|
||||
FROM shard_champion_spawns
|
||||
${where}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAtlas,
|
||||
getMeta,
|
||||
getFacets,
|
||||
getPending,
|
||||
setPending,
|
||||
clearPending,
|
||||
countCreatures,
|
||||
listCreatures,
|
||||
getCreature,
|
||||
listCreaturePlaces,
|
||||
listCreaturePoints,
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
}
|
||||
485
server/src/model/shardAtlas/shardAtlas.model.js
Normal file
485
server/src/model/shardAtlas/shardAtlas.model.js
Normal file
@@ -0,0 +1,485 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAtlas.db')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../utils/logger')('shardAtlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
//
|
||||
// The tree is the single source of truth. Nothing is precomputed and committed,
|
||||
// because a shard's maps change over its lifetime — facets get added, replaced
|
||||
// or renamed — and a snapshot in the repo would go stale against the world
|
||||
// players actually see. So the atlas is re-derived on every boot.
|
||||
//
|
||||
// Two rules govern the boot path:
|
||||
//
|
||||
// 1. **It never blocks startup.** No configured path, an unreadable path, a
|
||||
// malformed file, a database error — all of it is caught and logged. The
|
||||
// site comes up either way, serving whatever atlas it already had.
|
||||
// 2. **A facet disappearing is not applied automatically.** Losing a facet is
|
||||
// the signature of a half-copied or mid-update tree as much as of a real
|
||||
// map change, and the two are indistinguishable from here. The refresh is
|
||||
// staged for a human instead, and an admin approves or rejects it.
|
||||
//
|
||||
// Everything else — new facets, renamed regions, changed spawns — applies
|
||||
// straight away, because none of it can silently destroy data an operator would
|
||||
// miss.
|
||||
|
||||
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||
|
||||
/**
|
||||
* Where the ServUO tree lives.
|
||||
*
|
||||
* The admin setting wins over the environment so an operator can point the
|
||||
* atlas at a different tree without a redeploy, matching how the rest of the
|
||||
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
|
||||
* remains as the deploy-time default, since the path usually describes a mount
|
||||
* that the deployment sets up.
|
||||
*/
|
||||
async function getServuoPath() {
|
||||
try {
|
||||
const configured = await settings.get(SETTING_KEY)
|
||||
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||
} catch {
|
||||
// Settings unavailable is not fatal — fall through to the env default.
|
||||
}
|
||||
const fromEnv = process.env.SERVUO_PATH
|
||||
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||
}
|
||||
|
||||
async function setServuoPath(value, updatedBy = null) {
|
||||
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||
*
|
||||
* Never committed and never shipped — creature sprites come out of the
|
||||
* operator's own client `.mul`/`.uop` files, which are theirs, not ours to
|
||||
* redistribute. Absent (the normal case) every `art` stays NULL and the UI
|
||||
* renders text-only.
|
||||
*/
|
||||
function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) {
|
||||
try {
|
||||
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||
if (!fs.existsSync(file)) return {}
|
||||
const map = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
return map && typeof map === 'object' ? map : {}
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas art map could not be read', { error: err.message })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten each point's types into `shard_spawn_point_types` rows.
|
||||
*
|
||||
* A spawner may legitimately list the same type twice, and the primary key is
|
||||
* (point_id, slug), so duplicates collapse to the larger max rather than
|
||||
* failing the insert.
|
||||
*/
|
||||
function pointTypeRows(points) {
|
||||
const rows = []
|
||||
points.forEach((point, i) => {
|
||||
const bySlug = new Map()
|
||||
for (const entry of point.types ?? []) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||
}
|
||||
for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
async function applyAtlas(atlas) {
|
||||
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the atlas from the configured ServUO tree.
|
||||
*
|
||||
* Returns a result describing what happened rather than throwing, so the caller
|
||||
* — including the boot path — can log it and move on:
|
||||
*
|
||||
* `skipped` no path configured
|
||||
* `unavailable` path configured but unreadable / missing required files
|
||||
* `unchanged` source hashes match the loaded atlas; nothing parsed
|
||||
* `imported` parsed and applied
|
||||
* `needsReview` parsed, but a facet would be lost; staged for an admin
|
||||
* `failed` parsed or applied and something went wrong
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||
*/
|
||||
/**
|
||||
* Was the loaded atlas built by THIS parser?
|
||||
*
|
||||
* An atlas imported before `parserVersion` existed reports undefined, which is
|
||||
* correctly "no" — those are exactly the ones carrying the old readings.
|
||||
*/
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — it is a one-off "use this tree", and it
|
||||
// must not be silently overruled by the configured path the way an env default
|
||||
// would be.
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }
|
||||
|
||||
let hashes
|
||||
try {
|
||||
hashes = hashSources(root)
|
||||
} catch (err) {
|
||||
if (err instanceof AtlasSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
|
||||
: null
|
||||
|
||||
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
|
||||
// Only checking the tree would strand an install whose maps never change on
|
||||
// whatever an older build derived — a corrected parse would ship and never
|
||||
// reach the data.
|
||||
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
|
||||
return { status: 'unchanged', path: root }
|
||||
}
|
||||
|
||||
// A rejected refresh must not re-prompt on every boot. It stays rejected until
|
||||
// the tree changes again, at which point the hashes differ and it is a new
|
||||
// decision.
|
||||
const pending = await db.getPending().catch(() => null)
|
||||
if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
|
||||
return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
|
||||
}
|
||||
|
||||
let atlas
|
||||
try {
|
||||
atlas = buildAtlas(root)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
|
||||
const currentFacets = await db.getFacets().catch(() => [])
|
||||
const incomingFacets = atlas.facets
|
||||
const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet))
|
||||
const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet))
|
||||
|
||||
// Losing a facet is indistinguishable here from a half-copied tree, so it is
|
||||
// staged rather than applied — but startup is never blocked by it.
|
||||
if (removedFacets.length > 0 && !approve) {
|
||||
const summary = {
|
||||
hashes,
|
||||
path: root,
|
||||
currentFacets,
|
||||
incomingFacets,
|
||||
removedFacets,
|
||||
addedFacets,
|
||||
counts: atlas.meta.counts,
|
||||
}
|
||||
await db.setPending(summary, 'pending').catch((err) => {
|
||||
log.warn('could not stage spawn atlas refresh', { error: err.message })
|
||||
})
|
||||
return { status: 'needsReview', ...summary }
|
||||
}
|
||||
|
||||
try {
|
||||
const counts = await applyAtlas(atlas)
|
||||
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin approved a staged refresh: apply it, facet loss and all. */
|
||||
async function approvePending(options = {}) {
|
||||
return refresh({ ...options, approve: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin rejected a staged refresh: keep the current atlas and remember the
|
||||
* decision against those exact source hashes, so it does not re-prompt every
|
||||
* boot. A further change to the tree produces different hashes and asks again.
|
||||
*/
|
||||
async function rejectPending() {
|
||||
const pending = await db.getPending()
|
||||
if (!pending) return { status: 'none' }
|
||||
await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected')
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
|
||||
/** Everything the admin panel needs to describe atlas state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
const [meta, pending, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getPending().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
|
||||
let treeReadable = false
|
||||
let drift = null
|
||||
if (root !== '') {
|
||||
try {
|
||||
const hashes = hashSources(root)
|
||||
treeReadable = true
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||
: null
|
||||
// Same question `refresh` asks: an import picks something up when either
|
||||
// the tree or the parser has moved on.
|
||||
drift = !sameSources(hashes, loaded) || !currentParser(meta)
|
||||
} catch {
|
||||
treeReadable = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: root !== '',
|
||||
path: root,
|
||||
treeReadable,
|
||||
drift,
|
||||
facets,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
counts: meta?.counts ?? null,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||
* missing tree or a bad file can never stop the site coming up.
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
log.info('spawn atlas refreshed from ServUO tree', {
|
||||
...result.counts,
|
||||
added: result.addedFacets,
|
||||
})
|
||||
break
|
||||
case 'needsReview':
|
||||
log.warn(
|
||||
'spawn atlas refresh staged for admin review — a facet would be removed; ' +
|
||||
'the existing atlas is unchanged',
|
||||
{ removed: result.removedFacets, added: result.addedFacets },
|
||||
)
|
||||
break
|
||||
case 'unavailable':
|
||||
log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path })
|
||||
break
|
||||
case 'failed':
|
||||
log.warn('spawn atlas refresh failed', { reason: result.reason })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas refresh errored', { error: err.message })
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
|
||||
// than in the controller, for the same reason shardState does it: the column
|
||||
// names are an implementation detail of the import, and the browser contract
|
||||
// should not move when a column is renamed.
|
||||
|
||||
const jsonOr = (value, fallback) => {
|
||||
if (value == null) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const shapeCreature = (row) => ({
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
// `total` is the summed MaxCount across every spawner (how many can be alive
|
||||
// at once); `points` is how many spawners mention it. They answer different
|
||||
// questions and the UI shows both.
|
||||
total: row.total,
|
||||
points: row.points,
|
||||
facets: jsonOr(row.facets, {}),
|
||||
art: row.art || null,
|
||||
})
|
||||
|
||||
const shapePlace = (row) => ({
|
||||
facet: row.facet,
|
||||
label: row.label,
|
||||
spawners: Number(row.spawners) || 0,
|
||||
maxAlive: Number(row.max_alive) || 0,
|
||||
})
|
||||
|
||||
const shapePoint = (row) => ({
|
||||
id: row.id,
|
||||
facet: row.facet,
|
||||
name: row.name || null,
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
range: row.spawn_range,
|
||||
maxCount: row.max_count,
|
||||
minDelay: row.min_delay,
|
||||
maxDelay: row.max_delay,
|
||||
todStart: row.tod_start,
|
||||
todEnd: row.tod_end,
|
||||
todMode: row.tod_mode,
|
||||
region: row.region || null,
|
||||
landmark: row.landmark || null,
|
||||
label: row.label,
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated creature search. Returns the page plus the unpaginated total, so
|
||||
* the UI can say "showing 50 of 800" without a second round trip.
|
||||
*/
|
||||
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const [rows, total] = await Promise.all([
|
||||
db.listCreatures({ q, facet, limit, offset }),
|
||||
db.countCreatures({ q, facet }),
|
||||
])
|
||||
return { total, limit, offset, creatures: rows.map(shapeCreature) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One creature: its totals, the places it spawns (the aggregate the atlas
|
||||
* exists for), the individual spawners, and what else shares those spawners.
|
||||
*
|
||||
* `null` when the slug is unknown — the controller turns that into a 404.
|
||||
*/
|
||||
async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||
const row = await db.getCreature(slug)
|
||||
if (!row) return null
|
||||
const [places, pointRows, alsoHere] = await Promise.all([
|
||||
db.listCreaturePlaces(slug, { facet }),
|
||||
db.listCreaturePoints(slug, { facet, limit: points }),
|
||||
db.listCreatureCompanions(slug),
|
||||
])
|
||||
return {
|
||||
...shapeCreature(row),
|
||||
places: places.map(shapePlace),
|
||||
// `spawners`, not `points`: shapeCreature already uses `points` for the
|
||||
// COUNT of spawners, and reusing the key for the list of them would make the
|
||||
// same field a number on the search route and an array here.
|
||||
spawners: pointRows.map(shapePoint),
|
||||
// Bounded by the query, so a creature on hundreds of spawners returns a page
|
||||
// rather than the world.
|
||||
spawnersTruncated: pointRows.length >= points,
|
||||
alsoHere: alsoHere.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
shared: Number(r.shared) || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function listRegions(opts = {}) {
|
||||
const rows = await db.listRegions(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
type: r.type || null,
|
||||
priority: r.priority,
|
||||
parent: r.parent || null,
|
||||
rects: jsonOr(r.rects, []),
|
||||
}))
|
||||
}
|
||||
|
||||
async function listLandmarks(opts = {}) {
|
||||
const rows = await db.listLandmarks(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
}))
|
||||
}
|
||||
|
||||
async function listChampions(opts = {}) {
|
||||
const rows = await db.listChampions(opts)
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
// '' on the wire means "randomised at activation"; `randomType` says so
|
||||
// explicitly rather than making the client infer it from an empty string.
|
||||
type: r.type || null,
|
||||
randomType: !!r.random_type,
|
||||
facet: r.facet,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
radius: r.radius,
|
||||
label: r.label || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* What is loaded: the facet list, the counts, and when it was imported.
|
||||
*
|
||||
* Deliberately does NOT report the source path, the per-file hashes or whether
|
||||
* a refresh is pending. Those describe the operator's filesystem, and this is a
|
||||
* public endpoint; the admin status route carries them instead.
|
||||
*/
|
||||
async function publicMeta() {
|
||||
const [meta, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
return {
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
generatedAt: meta?.generatedAt ?? null,
|
||||
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
|
||||
// page state its own placement accuracy instead of implying it is complete.
|
||||
counts: meta?.counts ?? null,
|
||||
facets,
|
||||
}
|
||||
}
|
||||
|
||||
const listFacets = () => db.getFacets()
|
||||
|
||||
module.exports = {
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
approvePending,
|
||||
rejectPending,
|
||||
status,
|
||||
getServuoPath,
|
||||
setServuoPath,
|
||||
pointTypeRows,
|
||||
loadArtMap,
|
||||
SETTING_KEY,
|
||||
searchCreatures,
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
}
|
||||
108
server/src/model/shardClilocs/shardClilocs.db.js
Normal file
108
server/src/model/shardClilocs/shardClilocs.db.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the cliloc table. `shard_clilocs` is IMPORT-OWNED: `replaceAll`
|
||||
// empties and refills it inside one transaction, and nothing else in the
|
||||
// codebase writes to it. No foreign keys, consistent with every other shard_*
|
||||
// table.
|
||||
|
||||
const BATCH = 1000
|
||||
|
||||
/**
|
||||
* Replace the entire cliloc table in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a failed reload must leave the previous table
|
||||
* intact rather than a half-loaded one, because a partially-imported cliloc
|
||||
* table is indistinguishable from a complete one to anyone reading it — you
|
||||
* would just see some items named and some not, which is also what "no table at
|
||||
* all" looks like.
|
||||
*
|
||||
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||
* commits, which would defeat exactly that guarantee. (The same trap the spawn
|
||||
* atlas import documents; at ~123k rows `DELETE` is still well under a second.)
|
||||
*/
|
||||
async function replaceAll(entries, meta) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_clilocs')
|
||||
|
||||
// Blank entries are dropped rather than stored. Roughly HALF of a real
|
||||
// cliloc table is empty strings — ids the client reserves and never uses —
|
||||
// and a row that resolves to no name is indistinguishable from no row at
|
||||
// all to every caller. Dropping them halves the table (123,490 → ~67,500)
|
||||
// and, more importantly, makes the binary and text imports converge on
|
||||
// identical content: the binary format carries the blanks explicitly and a
|
||||
// text export may or may not, depending on the tool.
|
||||
//
|
||||
// Later duplicates win. Merging across sources already happened upstream in
|
||||
// `readCliloc`, so in practice this collapses nothing — it is kept because
|
||||
// the plain format permits a repeated id WITHIN one file and the client's
|
||||
// own loader resolves it the same way (its dictionary assignment
|
||||
// overwrites). Without it, a file the game itself would load happily would
|
||||
// fail the batch insert on a primary-key collision.
|
||||
const byNumber = new Map()
|
||||
let blank = 0
|
||||
for (const entry of entries) {
|
||||
if (!Number.isInteger(entry.number)) continue
|
||||
if (String(entry.text ?? '').trim() === '') {
|
||||
blank++
|
||||
continue
|
||||
}
|
||||
byNumber.set(entry.number, entry)
|
||||
}
|
||||
|
||||
const rows = [...byNumber.values()].map((e) => [e.number, e.flag ?? 0, e.text])
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch('INSERT INTO shard_clilocs (number, flag, text) VALUES (?,?,?)', rows.slice(i, i + BATCH))
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_cliloc_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...meta, count: rows.length })],
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return { count: rows.length, blank, duplicates: entries.length - blank - rows.length }
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_cliloc_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a batch of ids.
|
||||
*
|
||||
* Batched rather than one-at-a-time because every caller has a LIST: a character
|
||||
* sheet resolves a dozen equipment ids at once, and a page of marketplace
|
||||
* listings resolves fifty. `IN (...)` with generated placeholders keeps it one
|
||||
* round trip and one parameterized statement.
|
||||
*/
|
||||
async function lookup(numbers) {
|
||||
if (!Array.isArray(numbers) || numbers.length === 0) return []
|
||||
const ids = [...new Set(numbers.filter((n) => Number.isInteger(n)))]
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
return query(`SELECT number, text FROM shard_clilocs WHERE number IN (${placeholders})`, ids)
|
||||
}
|
||||
|
||||
async function count() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM shard_clilocs')
|
||||
return Number(rows[0]?.n) || 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAll,
|
||||
getMeta,
|
||||
lookup,
|
||||
count,
|
||||
}
|
||||
368
server/src/model/shardClilocs/shardClilocs.model.js
Normal file
368
server/src/model/shardClilocs/shardClilocs.model.js
Normal file
@@ -0,0 +1,368 @@
|
||||
const db = require('./shardClilocs.db')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { displayText } = require('../../utils/clilocParse')
|
||||
const {
|
||||
ClilocFormatError,
|
||||
ClilocSourceError,
|
||||
PARSER_VERSION,
|
||||
hashSources,
|
||||
sameSources,
|
||||
missingSources,
|
||||
readCliloc,
|
||||
} = require('../../utils/clilocSource')
|
||||
const log = require('../../utils/logger')('shardClilocs')
|
||||
|
||||
// The cliloc table — UO's id → display-string map, refreshed from a file the
|
||||
// operator converts once from their own client.
|
||||
//
|
||||
// Why the site holds this at all: items on the wire carry a `LabelNumber`, not a
|
||||
// name. `char.profile.equipment` has always sent `cliloc`, and every marketplace
|
||||
// listing sends one too. Without the table the UI can only print `id 1023721`
|
||||
// where the game prints "quarter staff".
|
||||
//
|
||||
// Two rules govern the boot path, both inherited from the spawn atlas:
|
||||
//
|
||||
// 1. **It never blocks startup.** No configured path, an unreadable file, a
|
||||
// wrong-format file, a database error — all caught and logged. The site
|
||||
// comes up either way, serving whatever table it already had (or none, in
|
||||
// which case the UI falls back to item ids exactly as it did before).
|
||||
// 2. **Nothing client-derived is committed.** The table is built from the
|
||||
// operator's own file at a configured path. The repo ships no strings.
|
||||
//
|
||||
// The table is built from a SET of sources — the converted client table plus
|
||||
// every operator-maintained overlay beside it — because shards edit items and
|
||||
// add new ones, and those carry cliloc ids no stock client table has. All of
|
||||
// them are re-read on every boot and hash-gated together, so adding one custom
|
||||
// item never means re-exporting a 5 MB client file. Later sources win.
|
||||
//
|
||||
// That set is also why this has the atlas's escalation, in a lighter form. A
|
||||
// single corrupt file fails the parse loudly, but a source that has simply
|
||||
// VANISHED parses perfectly and imports a table quietly missing everything it
|
||||
// contributed — the same ambiguity (real change vs half-copied mount) the atlas
|
||||
// stages a facet removal for. So a disappearing source is refused and reported
|
||||
// rather than applied.
|
||||
//
|
||||
// It is lighter than the atlas's because it needs to be: the atlas stores a
|
||||
// pending decision in its own table and adds approve/reject endpoints, whereas
|
||||
// here the decision is a single boolean an admin passes to the import they were
|
||||
// already going to run. Re-parsing at approval time — the property that makes
|
||||
// the atlas store only the decision — is automatic when there is nothing stored.
|
||||
|
||||
const SETTING_KEY = 'cliloc_client_path'
|
||||
|
||||
/**
|
||||
* Where the converted cliloc file lives.
|
||||
*
|
||||
* The admin setting wins over the environment so an operator can repoint it
|
||||
* without a redeploy, matching how the rest of the shard integration is
|
||||
* admin-managed rather than env-configured. `UO_CLIENT_PATH` remains as the
|
||||
* deploy-time default, since the path usually describes a mount the deployment
|
||||
* sets up.
|
||||
*/
|
||||
async function getClientPath() {
|
||||
try {
|
||||
const configured = await settings.get(SETTING_KEY)
|
||||
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||
} catch {
|
||||
// Settings unavailable is not fatal — fall through to the env default.
|
||||
}
|
||||
const fromEnv = process.env.UO_CLIENT_PATH
|
||||
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||
}
|
||||
|
||||
async function setClientPath(value, updatedBy = null) {
|
||||
const result = await settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||
invalidate()
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Was the loaded table built by THIS parser? */
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
/**
|
||||
* Refresh the cliloc table from the configured file.
|
||||
*
|
||||
* Returns a result describing what happened rather than throwing, so the caller
|
||||
* — including the boot path — can log it and move on:
|
||||
*
|
||||
* `skipped` no path configured
|
||||
* `unavailable` path configured but missing / unreadable / not a cliloc file
|
||||
* `unchanged` source hashes match the loaded table; nothing parsed
|
||||
* `imported` parsed and applied
|
||||
* `needsReview` a previously-present source has vanished; NOT applied
|
||||
* `failed` parsed or applied and something went wrong
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport). `approve`
|
||||
* additionally accepts a vanished source.
|
||||
*/
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — a one-off "use this file", which must
|
||||
// not be silently overruled by the configured path the way an env default is.
|
||||
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||
if (configured === '') return { status: 'skipped', reason: 'no cliloc path configured' }
|
||||
|
||||
let fingerprint
|
||||
try {
|
||||
fingerprint = hashSources(configured)
|
||||
} catch (err) {
|
||||
if (err instanceof ClilocSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
|
||||
// Two things make a loaded table stale: any source changed, or the PARSER did.
|
||||
// Only checking the sources would strand an install whose client never patches
|
||||
// on whatever an older build derived.
|
||||
if (!force && sameSources(fingerprint.hashes, meta?.hashes) && currentParser(meta)) {
|
||||
return {
|
||||
status: 'unchanged',
|
||||
path: configured,
|
||||
file: fingerprint.file,
|
||||
count: meta.count ?? null,
|
||||
customCount: fingerprint.customCount,
|
||||
}
|
||||
}
|
||||
|
||||
// A source that was there last import and is not there now is refused, not
|
||||
// applied — an unmounted volume and a deliberate deletion look identical from
|
||||
// here, and the wrong guess silently drops every name that file contributed.
|
||||
const gone = missingSources(fingerprint.hashes, meta?.hashes)
|
||||
if (gone.length > 0 && !approve) {
|
||||
return {
|
||||
status: 'needsReview',
|
||||
reason: `${gone.length} previously-loaded cliloc source(s) are missing; the existing table is unchanged`,
|
||||
missingSources: gone,
|
||||
path: configured,
|
||||
file: fingerprint.file,
|
||||
}
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = readCliloc(configured)
|
||||
} catch (err) {
|
||||
if (err instanceof ClilocFormatError || err instanceof ClilocSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
|
||||
try {
|
||||
const applied = await db.replaceAll(parsed.entries, parsed.source)
|
||||
invalidate()
|
||||
return {
|
||||
status: 'imported',
|
||||
path: configured,
|
||||
file: parsed.source.file,
|
||||
count: applied.count,
|
||||
parsed: parsed.entries.length,
|
||||
blank: applied.blank,
|
||||
// Per-source breakdown: how many entries each file contributed and how
|
||||
// many of them overrode something already merged. An operator who adds an
|
||||
// overlay wants to see it took effect, and "overrode: 0" on a file meant
|
||||
// to re-label stock items says it did not.
|
||||
sources: parsed.source.sources,
|
||||
acceptedMissing: gone.length > 0 ? gone : undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||
* missing or malformed cliloc file can never stop the site coming up.
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
log.info('cliloc table refreshed', {
|
||||
file: result.file,
|
||||
count: result.count,
|
||||
overlays: (result.sources || []).filter((s) => s.kind === 'custom').length,
|
||||
})
|
||||
break
|
||||
case 'needsReview':
|
||||
log.warn(
|
||||
'cliloc refresh staged for admin review — a previously-loaded source is missing; ' +
|
||||
'the existing table is unchanged',
|
||||
{ missing: result.missingSources },
|
||||
)
|
||||
break
|
||||
case 'unavailable':
|
||||
// Deliberately a warning, not an error: an operator who has not supplied
|
||||
// a cliloc file is in a supported state (the UI shows item ids), and the
|
||||
// most common cause — pointing at the client's own compressed file —
|
||||
// needs the reason spelled out rather than a stack trace.
|
||||
log.warn('cliloc source unavailable (item names will show as ids)', {
|
||||
reason: result.reason,
|
||||
code: result.code,
|
||||
path: result.path,
|
||||
})
|
||||
break
|
||||
case 'failed':
|
||||
log.warn('cliloc refresh failed', { reason: result.reason })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
log.warn('cliloc refresh errored', { error: err.message })
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the admin panel needs to describe cliloc state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = await db.count().catch(() => 0)
|
||||
|
||||
let fileReadable = false
|
||||
let file = null
|
||||
let drift = null
|
||||
let problem = null
|
||||
let code = null
|
||||
let sources = []
|
||||
let missing = []
|
||||
if (configured !== '') {
|
||||
try {
|
||||
const fingerprint = hashSources(configured)
|
||||
fileReadable = true
|
||||
file = fingerprint.file
|
||||
sources = Object.keys(fingerprint.hashes)
|
||||
missing = missingSources(fingerprint.hashes, meta?.hashes)
|
||||
// A compressed file is readable but not importable, and the panel has to
|
||||
// say so HERE — otherwise pointing at an unconverted client directory
|
||||
// reports a healthy file with pending drift ("ready to import") and the
|
||||
// operator only finds out when the import fails. `drift` stays null
|
||||
// because comparing hashes with an unusable file answers nothing.
|
||||
if (fingerprint.compressed) {
|
||||
problem =
|
||||
'This is a compressed (Mythic-format) cliloc file, which the site cannot read. ' +
|
||||
'Convert it to the plain format first — see docs/website/CLILOCS.md.'
|
||||
code = 'COMPRESSED'
|
||||
} else {
|
||||
drift = !sameSources(fingerprint.hashes, meta?.hashes) || !currentParser(meta)
|
||||
}
|
||||
} catch (err) {
|
||||
fileReadable = false
|
||||
problem = err.message
|
||||
code = err.code ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: configured !== '',
|
||||
path: configured,
|
||||
file,
|
||||
fileReadable,
|
||||
problem,
|
||||
code,
|
||||
drift,
|
||||
count: loaded,
|
||||
// Every source found now (base first, then overlays), what each contributed
|
||||
// at the last import, and any that have since vanished — which is the state
|
||||
// an import will refuse without `approve`.
|
||||
sources,
|
||||
loadedSources: meta?.sources ?? null,
|
||||
missingSources: missing,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
sourceBytes: meta?.bytes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolution happens SERVER-SIDE, not in the browser. Two reasons: the table is
|
||||
// ~123k rows and shipping it to a client would dwarf every page that uses it,
|
||||
// and the Android app consumes the same JSON and would otherwise need its own
|
||||
// copy. Callers get names, not ids-plus-a-table.
|
||||
|
||||
// A small write-through cache in front of the table. Item ids repeat heavily —
|
||||
// one page of listings is mostly the same few hundred clilocs, and a character
|
||||
// sheet re-resolves the same gear on every view — so this turns the steady state
|
||||
// into zero queries. Capped so a pathological caller cannot grow it without
|
||||
// bound; on overflow it is dropped wholesale rather than evicted entry-by-entry,
|
||||
// which is cheap and correct for a table that only changes on reimport.
|
||||
const CACHE_MAX = 20000
|
||||
let cache = new Map()
|
||||
|
||||
function invalidate() {
|
||||
cache = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a batch of cliloc ids to display strings.
|
||||
*
|
||||
* Returns a `Map<number, string>` holding only the ids that resolved to
|
||||
* something displayable — an id with no row, or one whose text is nothing but
|
||||
* interpolated arguments we do not have, is simply absent. Callers fall back to
|
||||
* whatever they had (the item id), so "missing" and "unnamed" collapse into one
|
||||
* branch at the call site.
|
||||
*
|
||||
* Never throws: a cliloc lookup is decoration on someone's character sheet, and
|
||||
* a database blip must not fail the sheet.
|
||||
*/
|
||||
async function resolveMany(numbers) {
|
||||
const out = new Map()
|
||||
if (!Array.isArray(numbers)) return out
|
||||
|
||||
const wanted = [...new Set(numbers.filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (wanted.length === 0) return out
|
||||
|
||||
const missing = []
|
||||
for (const number of wanted) {
|
||||
if (cache.has(number)) {
|
||||
const hit = cache.get(number)
|
||||
if (hit !== '') out.set(number, hit)
|
||||
} else {
|
||||
missing.push(number)
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
try {
|
||||
const rows = await db.lookup(missing)
|
||||
const found = new Map(rows.map((r) => [Number(r.number), displayText(r.text)]))
|
||||
if (cache.size + missing.length > CACHE_MAX) invalidate()
|
||||
for (const number of missing) {
|
||||
// Cache the miss too ('' meaning "no usable name"), so an id absent from
|
||||
// the table does not re-query on every page view.
|
||||
const text = found.get(number) ?? ''
|
||||
cache.set(number, text)
|
||||
if (text !== '') out.set(number, text)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('cliloc lookup failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Single-id convenience. Returns `null` when there is no usable name. */
|
||||
async function resolve(number) {
|
||||
const found = await resolveMany([number])
|
||||
return found.get(number) ?? null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SETTING_KEY,
|
||||
getClientPath,
|
||||
setClientPath,
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
status,
|
||||
resolveMany,
|
||||
resolve,
|
||||
invalidate,
|
||||
}
|
||||
@@ -16,6 +16,11 @@ async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
||||
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||
// staff/sensitive kinds. limit is clamped by the model.
|
||||
async function list({ kind, kinds, limit }) {
|
||||
// An allowlist that resolved to NOTHING means "serve nothing" — never "serve
|
||||
// everything". Falling through to the unfiltered query below would have turned
|
||||
// a fully-gated visibility config into a full dump of the event log, staff
|
||||
// audit and cheat detections included.
|
||||
if (kinds && kinds.length === 0) return []
|
||||
if (kinds && kinds.length) {
|
||||
const placeholders = kinds.map(() => '?').join(', ')
|
||||
return query(
|
||||
|
||||
299
server/src/model/shardMarket/shardMarket.db.js
Normal file
299
server/src/model/shardMarket/shardMarket.db.js
Normal file
@@ -0,0 +1,299 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the player-vendor market index (Protocol 3.0 vendor.listing).
|
||||
//
|
||||
// Two tables, both INGEST-OWNED: `shard_vendors` (one row per shop) and
|
||||
// `shard_vendor_items` (one row per priced listing). Nothing else in the codebase
|
||||
// writes to either. No foreign keys, consistent with every other shard_* table.
|
||||
|
||||
// Insert batch size for one vendor's listings. A shop is capped at
|
||||
// MarketMaxListings (250 by default) on the shard side, so in practice this is
|
||||
// one batch — it exists for the operator who raised that cap.
|
||||
const BATCH = 500
|
||||
|
||||
// LIKE wildcards in user input. `%` and `_` are not special to the parameterized
|
||||
// query — they are special to LIKE itself — so a search for "50% off" would
|
||||
// otherwise match everything containing "50" and a search for "_" would match
|
||||
// every single-character name. Escaped with a backslash, which is MariaDB's
|
||||
// default LIKE escape (no ESCAPE clause needed).
|
||||
const likeTerm = (q) => `%${String(q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
||||
|
||||
/**
|
||||
* Replace one vendor's whole row and listing set, in one transaction.
|
||||
*
|
||||
* Delete-then-insert rather than a diff, because the frame is AUTHORITATIVE for
|
||||
* that vendor: the shard's sweep only emits a shop whose contents, prices or
|
||||
* location moved, and when it does it sends the whole shop. Reconciling it item
|
||||
* by item would be more code for the same result and would leave sold items
|
||||
* behind on any path the reconciliation missed.
|
||||
*
|
||||
* All-or-nothing matters here for a specific reason: the two writes are "the
|
||||
* shop" and "what is in it", and a failure between them leaves a shop advertising
|
||||
* an inventory it no longer has (or none at all) — visibly wrong on the page, and
|
||||
* indistinguishable from a genuinely empty shop.
|
||||
*/
|
||||
async function replaceVendor(vendor, items) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
await conn.query(
|
||||
`INSERT INTO shard_vendors
|
||||
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
|
||||
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
|
||||
z = VALUES(z), region = VALUES(region), house = VALUES(house),
|
||||
item_count = VALUES(item_count), item_total = VALUES(item_total),
|
||||
truncated = VALUES(truncated), t = VALUES(t),
|
||||
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
|
||||
-- MariaDB does not fire that when every column is written back
|
||||
-- unchanged, and a shop that is re-published identically is still
|
||||
-- FRESHLY CONFIRMED. Without this the staleness banner would age a
|
||||
-- perfectly current shop forever.
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
vendor.serial,
|
||||
vendor.shopName ?? null,
|
||||
vendor.ownerSerial ?? null,
|
||||
vendor.ownerName ?? null,
|
||||
vendor.map ?? null,
|
||||
Number.isFinite(vendor.x) ? vendor.x : null,
|
||||
Number.isFinite(vendor.y) ? vendor.y : null,
|
||||
Number.isFinite(vendor.z) ? vendor.z : null,
|
||||
vendor.region ?? null,
|
||||
vendor.house ?? null,
|
||||
items.length,
|
||||
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
|
||||
vendor.truncated ? 1 : 0,
|
||||
Number.isFinite(vendor.t) ? vendor.t : null,
|
||||
],
|
||||
)
|
||||
|
||||
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [vendor.serial])
|
||||
|
||||
const rows = items.map((i) => [
|
||||
vendor.serial,
|
||||
i.serial,
|
||||
i.itemId,
|
||||
i.hue,
|
||||
i.amount,
|
||||
i.price,
|
||||
i.name,
|
||||
i.cliloc,
|
||||
i.displayName,
|
||||
i.child ? 1 : 0,
|
||||
])
|
||||
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(
|
||||
`INSERT INTO shard_vendor_items
|
||||
(vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
rows.slice(i, i + BATCH),
|
||||
)
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
return { items: rows.length }
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop one vendor and its listings (vendor.listing.remove). */
|
||||
async function removeVendor(serial) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [serial])
|
||||
await conn.query('DELETE FROM shard_vendors WHERE serial = ?', [serial])
|
||||
await conn.commit()
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The unit of a search RESULT is a listing, not a vendor: "who sells a vanquishing
|
||||
// kryss and for how much" is the question, and answering it per vendor would make
|
||||
// the caller flatten the shops back out. The vendor's columns ride along on the
|
||||
// join so a result row is self-contained.
|
||||
|
||||
function searchWhere({ q, minPrice, maxPrice, itemId, map, region }) {
|
||||
const where = ['i.price > 0']
|
||||
const params = []
|
||||
|
||||
if (q) {
|
||||
// Both the resolved display name and the item's own literal, because an item
|
||||
// with a player-set name (most of what is actually worth searching for on a
|
||||
// player-run shard) may have a generic cliloc.
|
||||
where.push('(i.display_name LIKE ? OR i.name LIKE ?)')
|
||||
params.push(likeTerm(q), likeTerm(q))
|
||||
}
|
||||
if (Number.isFinite(minPrice)) {
|
||||
where.push('i.price >= ?')
|
||||
params.push(minPrice)
|
||||
}
|
||||
if (Number.isFinite(maxPrice)) {
|
||||
where.push('i.price <= ?')
|
||||
params.push(maxPrice)
|
||||
}
|
||||
if (Number.isFinite(itemId)) {
|
||||
where.push('i.item_id = ?')
|
||||
params.push(itemId)
|
||||
}
|
||||
if (map) {
|
||||
where.push('v.map = ?')
|
||||
params.push(map)
|
||||
}
|
||||
if (region) {
|
||||
where.push('v.region = ?')
|
||||
params.push(region)
|
||||
}
|
||||
|
||||
return { sql: `WHERE ${where.join(' AND ')}`, params }
|
||||
}
|
||||
|
||||
// Whitelisted, because this interpolates into the statement. `recent` sorts by
|
||||
// the vendor's freshness, which is the only way to see what has just been listed
|
||||
// on a shard whose sweep is minutes wide.
|
||||
const SORTS = {
|
||||
price_asc: 'i.price ASC, i.id ASC',
|
||||
price_desc: 'i.price DESC, i.id ASC',
|
||||
recent: 'v.updated_at DESC, i.id ASC',
|
||||
}
|
||||
|
||||
async function searchListings({ q, minPrice, maxPrice, itemId, map, region, sort, limit, offset }) {
|
||||
const { sql, params } = searchWhere({ q, minPrice, maxPrice, itemId, map, region })
|
||||
const order = SORTS[sort] || SORTS.price_asc
|
||||
|
||||
const rows = await query(
|
||||
`SELECT i.serial, i.item_id, i.hue, i.amount, i.price, i.name, i.cliloc, i.display_name, i.child,
|
||||
v.serial AS vendor_serial, v.shop_name, v.owner_serial, v.owner_name,
|
||||
v.map, v.x, v.y, v.z, v.region, v.house, v.updated_at
|
||||
FROM shard_vendor_items i
|
||||
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||
${sql}
|
||||
ORDER BY ${order}
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
|
||||
const counted = await query(
|
||||
`SELECT COUNT(*) AS n
|
||||
FROM shard_vendor_items i
|
||||
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||
${sql}`,
|
||||
params,
|
||||
)
|
||||
|
||||
return { rows, total: Number(counted[0]?.n) || 0 }
|
||||
}
|
||||
|
||||
async function getVendor(serial) {
|
||||
const rows = await query(
|
||||
`SELECT serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t, updated_at
|
||||
FROM shard_vendors WHERE serial = ?`,
|
||||
[serial],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function listVendorItems(serial, { limit, offset }) {
|
||||
return query(
|
||||
`SELECT serial, item_id, hue, amount, price, name, cliloc, display_name, child
|
||||
FROM shard_vendor_items
|
||||
WHERE vendor_serial = ?
|
||||
ORDER BY price ASC, id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[serial, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the market page's header needs: how big the index is, and how stale it may
|
||||
* be. `staleAt` is the OLDEST vendor row — the round-robin sweep means a shop can
|
||||
* be a full cycle behind, and the page says so rather than implying live prices.
|
||||
*/
|
||||
async function meta() {
|
||||
const rows = await query(
|
||||
`SELECT COUNT(*) AS vendors, MIN(updated_at) AS stale_at, MAX(updated_at) AS fresh_at
|
||||
FROM shard_vendors`,
|
||||
)
|
||||
const items = await query('SELECT COUNT(*) AS n FROM shard_vendor_items')
|
||||
return {
|
||||
vendors: Number(rows[0]?.vendors) || 0,
|
||||
items: Number(items[0]?.n) || 0,
|
||||
staleAt: rows[0]?.stale_at || null,
|
||||
freshAt: rows[0]?.fresh_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The distinct facets and regions holding vendors — drives the page's filters. */
|
||||
async function listPlaces() {
|
||||
const maps = await query(
|
||||
'SELECT DISTINCT map FROM shard_vendors WHERE map IS NOT NULL ORDER BY map',
|
||||
)
|
||||
const regions = await query(
|
||||
'SELECT DISTINCT region FROM shard_vendors WHERE region IS NOT NULL ORDER BY region',
|
||||
)
|
||||
return { maps: maps.map((r) => r.map), regions: regions.map((r) => r.region) }
|
||||
}
|
||||
|
||||
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One page of listings whose name still needs resolving, for the bulk pass that
|
||||
* runs after a cliloc import.
|
||||
*
|
||||
* Keyed on `id > after` rather than OFFSET: the pass updates the very rows it is
|
||||
* scanning, and an OFFSET walk over a table being rewritten skips rows. Every
|
||||
* row with a cliloc is re-read, not just the unresolved ones, because an import
|
||||
* can also CHANGE a name — a shard overlay relabelling a stock item is the whole
|
||||
* reason overlays exist.
|
||||
*/
|
||||
async function listResolvableItems(after, limit) {
|
||||
return query(
|
||||
`SELECT id, cliloc, name, display_name
|
||||
FROM shard_vendor_items
|
||||
WHERE cliloc IS NOT NULL AND cliloc > 0 AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?`,
|
||||
[after, limit],
|
||||
)
|
||||
}
|
||||
|
||||
/** Write back a batch of re-resolved display names. */
|
||||
async function updateDisplayNames(pairs) {
|
||||
if (pairs.length === 0) return 0
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.batch('UPDATE shard_vendor_items SET display_name = ? WHERE id = ?', pairs)
|
||||
return pairs.length
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceVendor,
|
||||
removeVendor,
|
||||
searchListings,
|
||||
getVendor,
|
||||
listVendorItems,
|
||||
meta,
|
||||
listPlaces,
|
||||
listResolvableItems,
|
||||
updateDisplayNames,
|
||||
likeTerm,
|
||||
}
|
||||
329
server/src/model/shardMarket/shardMarket.model.js
Normal file
329
server/src/model/shardMarket/shardMarket.model.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// ── Player-vendor market index (Protocol 3.0 vendor.listing) ───────────────
|
||||
//
|
||||
// The shard-wide shop index: what every player vendor is selling, for how much,
|
||||
// and where it is standing. This is the website's half of the search the in-game
|
||||
// Vendor Search gump offers — the same data, the same opt-out, reachable without
|
||||
// logging in to the game.
|
||||
//
|
||||
// Ingest is per-vendor and authoritative: the shard's round-robin sweep emits one
|
||||
// `vendor.listing` frame per shop whose contents, prices or location moved, and
|
||||
// the frame is the whole shop (see docs/link/v3.md §8 and BridgeMarket.cs). This
|
||||
// module normalizes it into shard_vendors + shard_vendor_items and, crucially,
|
||||
// resolves each listing's cliloc to a DISPLAY NAME on the way in — a search for
|
||||
// "kryss" is a search over names, and the shard only ever sends numbers.
|
||||
|
||||
const db = require('./shardMarket.db')
|
||||
const clilocs = require('../shardClilocs/shardClilocs.model')
|
||||
const log = require('../../utils/logger')('shard-market')
|
||||
|
||||
// Defense in depth on top of the shard's own MarketMaxListings cap. The shard is
|
||||
// trusted, but it is a separately-versioned component: a frame from a plugin
|
||||
// whose cap was raised (or a shard running modified scripts) must not be able to
|
||||
// turn one ingest into an unbounded transaction.
|
||||
const MAX_ITEMS_PER_VENDOR = 5000
|
||||
|
||||
// Column widths in schema.sql. Truncating here rather than letting MariaDB do it
|
||||
// keeps the behavior the same in strict mode, where an over-length value is an
|
||||
// ERROR and would fail the whole vendor rather than shortening one name.
|
||||
const MAX_NAME = 160
|
||||
const MAX_SHOP = 160
|
||||
const MAX_OWNER = 64
|
||||
const MAX_MAP = 40
|
||||
const MAX_REGION = 80
|
||||
const MAX_SERIAL = 20
|
||||
|
||||
const clip = (value, max) => {
|
||||
if (value == null) return null
|
||||
const s = String(value)
|
||||
return s.length > max ? s.slice(0, max) : s
|
||||
}
|
||||
|
||||
const int = (value, fallback = 0) => {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten one `vendor.listing` frame into the row shapes the DB layer wants.
|
||||
*
|
||||
* `location` arrives as a nested object rather than flat map/x/y/region, and that
|
||||
* shape is load-bearing rather than cosmetic: the visibility projection matches
|
||||
* literal JSON keys, so ONE `market.location` rule can hide a vendor's
|
||||
* whereabouts only if `location` is a single key on both the live frame and the
|
||||
* stored read model. Flattening it here for storage and re-nesting it on read is
|
||||
* what keeps that true on both paths.
|
||||
*
|
||||
* Exported for tests — it is the part with rules in it, and it is pure.
|
||||
*/
|
||||
function flattenFrame(ev) {
|
||||
const loc = (ev && ev.location) || {}
|
||||
return {
|
||||
serial: clip(ev.serial, MAX_SERIAL),
|
||||
shopName: clip(ev.shopName, MAX_SHOP),
|
||||
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
|
||||
ownerName: clip(ev.ownerName, MAX_OWNER),
|
||||
map: clip(loc.map, MAX_MAP),
|
||||
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
|
||||
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
|
||||
z: Number.isFinite(loc.z) ? Math.trunc(loc.z) : null,
|
||||
region: clip(loc.region, MAX_REGION),
|
||||
house: clip(loc.house, MAX_SHOP),
|
||||
// What the SHOP holds, which is not what the frame carries when it was
|
||||
// truncated. Kept apart so the page can say "showing 250 of 3,104" rather
|
||||
// than presenting a partial shop as a complete one.
|
||||
itemTotal: int(ev.total, int(ev.count, 0)),
|
||||
truncated: ev.truncated === true,
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each listing's display name.
|
||||
*
|
||||
* Order of preference is the item's own literal `name` first, then the cliloc.
|
||||
* That is the opposite of what "resolve the id" suggests and it is right: a
|
||||
* literal name only exists because a player set one ("Bob's vanquishing kryss"),
|
||||
* and it is strictly more specific than the generic cliloc the item still
|
||||
* carries.
|
||||
*
|
||||
* One batched lookup per frame rather than per item; `resolveMany` is cached and
|
||||
* never throws, so a cliloc table that is missing entirely just leaves
|
||||
* `displayName` null and the page renders item ids, exactly as it did before the
|
||||
* table existed.
|
||||
*/
|
||||
async function shapeItems(ev) {
|
||||
const raw = Array.isArray(ev.items) ? ev.items.slice(0, MAX_ITEMS_PER_VENDOR) : []
|
||||
|
||||
const wanted = raw
|
||||
.map((i) => int(i && i.cliloc, 0))
|
||||
.filter((n) => n > 0)
|
||||
|
||||
const names = await clilocs.resolveMany(wanted)
|
||||
|
||||
return raw
|
||||
.filter((i) => i && i.serial)
|
||||
.map((i) => {
|
||||
const literal = clip(i.name, MAX_NAME)
|
||||
const cliloc = int(i.cliloc, 0) || null
|
||||
return {
|
||||
serial: clip(i.serial, MAX_SERIAL),
|
||||
itemId: int(i.itemId, 0),
|
||||
hue: int(i.hue, 0),
|
||||
amount: int(i.amount, 1),
|
||||
price: int(i.price, 0),
|
||||
name: literal,
|
||||
cliloc,
|
||||
displayName: literal || (cliloc ? clip(names.get(cliloc) ?? null, MAX_NAME) : null),
|
||||
child: i.child === true,
|
||||
}
|
||||
})
|
||||
// Unpriced rows are inventory, not listings. The shard already drops them;
|
||||
// this is the same rule enforced where the table is written, so a plugin that
|
||||
// stops enforcing it cannot put un-buyable rows on the market page.
|
||||
.filter((i) => i.price > 0)
|
||||
}
|
||||
|
||||
/** Ingest one `vendor.listing` frame. */
|
||||
async function upsertVendor(ev) {
|
||||
if (!ev || !ev.serial) return
|
||||
const vendor = flattenFrame(ev)
|
||||
const items = await shapeItems(ev)
|
||||
await db.replaceVendor(vendor, items)
|
||||
}
|
||||
|
||||
/** Ingest one `vendor.listing.remove` frame. */
|
||||
async function removeVendor(serial) {
|
||||
if (!serial) return
|
||||
await db.removeVendor(String(serial).slice(0, MAX_SERIAL))
|
||||
}
|
||||
|
||||
// ── Read models ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `location` is re-nested (see flattenFrame) so the stored read model and the
|
||||
// live wire frame present the same keys to the visibility projection.
|
||||
|
||||
const place = (r) => ({
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
region: r.region,
|
||||
house: r.house,
|
||||
})
|
||||
|
||||
// A listing as the search returns it: the item, plus enough of its shop to be
|
||||
// actionable without a second request. `displayName` falls back to nothing rather
|
||||
// than to a fabricated "Item 3922" — the client decides how to render an
|
||||
// unresolved id, and inventing a name here would make it indistinguishable from
|
||||
// a real one.
|
||||
const shapeListing = (r) => ({
|
||||
serial: r.serial,
|
||||
itemId: r.item_id,
|
||||
hue: r.hue,
|
||||
amount: r.amount,
|
||||
price: Number(r.price),
|
||||
name: r.name,
|
||||
cliloc: r.cliloc,
|
||||
displayName: r.display_name,
|
||||
child: !!r.child,
|
||||
vendor: {
|
||||
serial: r.vendor_serial,
|
||||
shopName: r.shop_name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerName: r.owner_name,
|
||||
location: place(r),
|
||||
updatedAt: r.updated_at,
|
||||
},
|
||||
})
|
||||
|
||||
const shapeVendor = (r) => ({
|
||||
serial: r.serial,
|
||||
shopName: r.shop_name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerName: r.owner_name,
|
||||
location: place(r),
|
||||
count: r.item_count,
|
||||
total: r.item_total,
|
||||
truncated: !!r.truncated,
|
||||
updatedAt: r.updated_at,
|
||||
})
|
||||
|
||||
const shapeItem = (r) => ({
|
||||
serial: r.serial,
|
||||
itemId: r.item_id,
|
||||
hue: r.hue,
|
||||
amount: r.amount,
|
||||
price: Number(r.price),
|
||||
name: r.name,
|
||||
cliloc: r.cliloc,
|
||||
displayName: r.display_name,
|
||||
child: !!r.child,
|
||||
})
|
||||
|
||||
/**
|
||||
* Search the index. Returns a page of LISTINGS (not vendors) plus the
|
||||
* unpaginated total and the staleness stamp the page's banner needs.
|
||||
*/
|
||||
async function search({
|
||||
q = '',
|
||||
minPrice,
|
||||
maxPrice,
|
||||
itemId,
|
||||
map = '',
|
||||
region = '',
|
||||
sort = 'price_asc',
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = {}) {
|
||||
const { rows, total } = await db.searchListings({
|
||||
q: q.trim(),
|
||||
minPrice: Number.isFinite(minPrice) ? minPrice : undefined,
|
||||
maxPrice: Number.isFinite(maxPrice) ? maxPrice : undefined,
|
||||
itemId: Number.isFinite(itemId) ? itemId : undefined,
|
||||
map: map.trim(),
|
||||
region: region.trim(),
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
const info = await db.meta()
|
||||
|
||||
return {
|
||||
listings: rows.map(shapeListing),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
// Repeated on every search response rather than left to a separate /meta
|
||||
// call: the banner that says how old these prices are must age with the
|
||||
// results it labels, and a client that fetched it once would keep showing a
|
||||
// stamp from before the page it is looking at.
|
||||
staleAt: info.staleAt,
|
||||
vendors: info.vendors,
|
||||
}
|
||||
}
|
||||
|
||||
/** One shop and its listings. `null` when the index has never seen that serial. */
|
||||
async function getVendor(serial, { limit = 250, offset = 0 } = {}) {
|
||||
const row = await db.getVendor(serial)
|
||||
if (!row) return null
|
||||
const items = await db.listVendorItems(serial, { limit, offset })
|
||||
return { ...shapeVendor(row), items: items.map(shapeItem) }
|
||||
}
|
||||
|
||||
/** Index size, staleness, and the facet/region filter options. */
|
||||
async function meta() {
|
||||
const [info, places] = await Promise.all([db.meta(), db.listPlaces()])
|
||||
return { ...info, ...places }
|
||||
}
|
||||
|
||||
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||
|
||||
// Batch size for the post-import pass. Big enough that a 40k-row table is ~40
|
||||
// round trips, small enough that a single batch is not a long-held connection.
|
||||
const RESOLVE_BATCH = 1000
|
||||
|
||||
/**
|
||||
* Re-resolve every listing's display name against the current cliloc table.
|
||||
*
|
||||
* Called after a cliloc import, and it has to be: the market's diff sweep will
|
||||
* NOT re-send an unchanged shop just because the site learned what its items are
|
||||
* called, so without this an operator who configures clilocs after the first
|
||||
* market sweep sees item ids until every shop happens to change. That is the same
|
||||
* class of staleness the spawn atlas avoids by re-parsing on boot — here the
|
||||
* source of truth for names moved, not the data.
|
||||
*
|
||||
* Never throws. It is a cosmetic backfill on a table that is already serving; a
|
||||
* failure means names stay as they were, which is exactly the pre-import state.
|
||||
*/
|
||||
async function refreshDisplayNames() {
|
||||
let after = 0
|
||||
let scanned = 0
|
||||
let changed = 0
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const rows = await db.listResolvableItems(after, RESOLVE_BATCH)
|
||||
if (rows.length === 0) break
|
||||
|
||||
after = rows[rows.length - 1].id
|
||||
scanned += rows.length
|
||||
|
||||
const names = await clilocs.resolveMany(rows.map((r) => Number(r.cliloc)))
|
||||
|
||||
const pairs = []
|
||||
for (const row of rows) {
|
||||
// The literal name still wins, so a re-resolution never overwrites a
|
||||
// player-set name with the generic cliloc behind it.
|
||||
const next = row.name
|
||||
? clip(row.name, MAX_NAME)
|
||||
: clip(names.get(Number(row.cliloc)) ?? null, MAX_NAME)
|
||||
if (next !== row.display_name) pairs.push([next, row.id])
|
||||
}
|
||||
|
||||
changed += await db.updateDisplayNames(pairs)
|
||||
}
|
||||
|
||||
if (changed > 0) log.info('market display names refreshed', { scanned, changed })
|
||||
return { scanned, changed }
|
||||
} catch (err) {
|
||||
log.warn('market display-name refresh failed', { message: err.message, scanned, changed })
|
||||
return { scanned, changed, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertVendor,
|
||||
removeVendor,
|
||||
search,
|
||||
getVendor,
|
||||
meta,
|
||||
refreshDisplayNames,
|
||||
flattenFrame,
|
||||
shapeItems,
|
||||
shapeListing,
|
||||
shapeVendor,
|
||||
MAX_ITEMS_PER_VENDOR,
|
||||
}
|
||||
@@ -259,6 +259,69 @@ async function latestPresence() {
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
||||
// Singleton, same shape as shard_presence: the shard re-emits the whole frame on
|
||||
// every connect, so there is nothing to merge — the latest one wins outright.
|
||||
async function setRuleset({ rev, expansion, payload, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_ruleset (id, rev, expansion, payload, t) VALUES (1, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE rev = VALUES(rev), expansion = VALUES(expansion),
|
||||
payload = VALUES(payload), t = VALUES(t)`,
|
||||
[rev ?? null, expansion ?? null, payload, Number.isFinite(t) ? t : null],
|
||||
)
|
||||
}
|
||||
|
||||
async function getRuleset() {
|
||||
const rows = await query(
|
||||
'SELECT rev, expansion, payload, t, updated_at FROM shard_ruleset WHERE id = 1',
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
// One row per point system. The shard only emits a system whose top N actually
|
||||
// moved, so this is a sparse stream of overwrites; there is no delete, because
|
||||
// the shard's set of systems is fixed at startup.
|
||||
async function upsertPointsBoard({ system, name, nameCliloc, maxPoints, players, showOnGump, payload, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_points_boards
|
||||
(system, name, name_cliloc, max_points, players, show_on_gump, payload, t)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), name_cliloc = VALUES(name_cliloc),
|
||||
max_points = VALUES(max_points), players = VALUES(players),
|
||||
show_on_gump = VALUES(show_on_gump), payload = VALUES(payload), t = VALUES(t)`,
|
||||
[
|
||||
system,
|
||||
name ?? null,
|
||||
Number.isFinite(nameCliloc) ? nameCliloc : null,
|
||||
Number.isFinite(maxPoints) ? maxPoints : null,
|
||||
Number.isFinite(players) ? players : null,
|
||||
showOnGump ? 1 : 0,
|
||||
payload,
|
||||
Number.isFinite(t) ? t : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// Ordered by display name, falling back to the system key for a board whose name
|
||||
// arrived as a bare cliloc — otherwise every unresolved board would sort together
|
||||
// under NULL.
|
||||
async function listPointsBoards() {
|
||||
return query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards ORDER BY COALESCE(name, system), system`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const rows = await query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards WHERE system = ?`,
|
||||
[system],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -290,6 +353,11 @@ module.exports = {
|
||||
listGovernorTerms,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
|
||||
@@ -508,6 +508,79 @@ async function latestPresence() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
||||
//
|
||||
// The whole frame is stored in `payload` and served back whole. Nothing is
|
||||
// normalized out of it: it is a flat description of config read as one page, and
|
||||
// splitting it into columns would mean a schema change every time the shard grows
|
||||
// a new block. `rev` and `expansion` are hoisted only because they are cheap to
|
||||
// index/display, following shard_champs' payload-plus-hoisted-columns pattern.
|
||||
async function setRuleset(ev) {
|
||||
if (!ev) return
|
||||
await db.setRuleset({
|
||||
rev: ev.rev ?? null,
|
||||
expansion: ev.expansion ?? null,
|
||||
payload: JSON.stringify(ev),
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
// The stored ruleset, or null when the shard has never published one (an old
|
||||
// plugin, or Bridge.RulesetEnabled=false). Null is a real answer here — the page
|
||||
// says "not published yet" rather than rendering an empty ruleset as if the shard
|
||||
// had no rules — so it is deliberately not smoothed into {}.
|
||||
async function getRuleset() {
|
||||
const r = await db.getRuleset()
|
||||
if (!r) return null
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
if (!payload) return null
|
||||
return { ...payload, updatedAt: r.updated_at }
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
//
|
||||
// The whole frame is stored in `payload`; the columns beside it are hoisted for
|
||||
// listing and ordering only. The top-N list deliberately stays inside the payload
|
||||
// (see schema.sql) — it is a fixed-size list read whole, like the governor board's
|
||||
// candidates.
|
||||
async function upsertPointsBoard(ev) {
|
||||
if (!ev || !ev.system) return
|
||||
await db.upsertPointsBoard({
|
||||
system: String(ev.system).slice(0, 48),
|
||||
name: ev.nameString ?? null,
|
||||
nameCliloc: ev.nameNumber,
|
||||
maxPoints: ev.maxPoints,
|
||||
players: ev.players,
|
||||
showOnGump: ev.showOnGump !== false,
|
||||
payload: JSON.stringify(ev),
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
// A stored frame plus the freshness stamp. `top` is normalized to an array so a
|
||||
// caller never has to guard it — a board with nobody on it is a real state (a
|
||||
// system nobody has scored in yet), distinct from a system that was never
|
||||
// published at all, which is absent from the table entirely.
|
||||
function shapePointsBoard(r) {
|
||||
const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {}
|
||||
return {
|
||||
...payload,
|
||||
system: r.system,
|
||||
top: Array.isArray(payload.top) ? payload.top : [],
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listPointsBoards() {
|
||||
const rows = await db.listPointsBoards()
|
||||
return rows.map(shapePointsBoard)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const r = await db.getPointsBoard(system)
|
||||
return r ? shapePointsBoard(r) : null
|
||||
}
|
||||
|
||||
function safeJson(s) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
@@ -557,4 +630,9 @@ module.exports = {
|
||||
replaceGovernors,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
}
|
||||
|
||||
37
server/src/model/shardVisibility/shardVisibility.db.js
Normal file
37
server/src/model/shardVisibility/shardVisibility.db.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
|
||||
// compiles a default for every known feature and merges stored rows over it, so
|
||||
// a fresh install with an empty table behaves exactly as the site did pre-v3.
|
||||
|
||||
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
|
||||
|
||||
const getOne = (feature) =>
|
||||
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
|
||||
|
||||
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
|
||||
// {field: rung}; the caller has already stripped locked fields and validated
|
||||
// every rung against the ladder.
|
||||
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
|
||||
query(
|
||||
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
audience = VALUES(audience),
|
||||
stream = VALUES(stream),
|
||||
field_rules = VALUES(field_rules),
|
||||
updated_by = VALUES(updated_by)`,
|
||||
[
|
||||
feature,
|
||||
enabled ? 1 : 0,
|
||||
audience,
|
||||
stream ? 1 : 0,
|
||||
fieldRules == null ? null : JSON.stringify(fieldRules),
|
||||
updatedBy ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
44
server/src/model/shardVisibility/shardVisibility.model.js
Normal file
44
server/src/model/shardVisibility/shardVisibility.model.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// ── Shard feature visibility (model) ───────────────────────────────────────
|
||||
//
|
||||
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
|
||||
// feature catalog, the locked fields, the kind→feature map — lives in
|
||||
// utils/shardVisibility.js; this file only reads and writes rows.
|
||||
|
||||
const db = require('./shardVisibility.db')
|
||||
|
||||
// The `field_rules` JSON column comes back as a string on the mariadb driver.
|
||||
function parseRules(raw) {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'object') return raw
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const toSafe = (row) =>
|
||||
row && {
|
||||
feature: row.feature,
|
||||
enabled: !!row.enabled,
|
||||
audience: row.audience,
|
||||
stream: row.stream == null ? null : !!row.stream,
|
||||
fieldRules: parseRules(row.field_rules),
|
||||
updatedBy: row.updated_by,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
|
||||
async function listAll() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
async function getOne(feature) {
|
||||
const rows = await db.getOne(feature)
|
||||
return toSafe(rows[0])
|
||||
}
|
||||
|
||||
const upsert = (entry) => db.upsert(entry)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
103
server/src/model/trustedDevices/trustedDevices.db.js
Normal file
103
server/src/model/trustedDevices/trustedDevices.db.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for the trusted_devices table. The opaque trust token lives client-side; the
|
||||
// DB stores only its sha256 hash (token_hash). Mirrors mobileSessions.db — a
|
||||
// trusted device is the MFA analogue of a live session: it lets a login skip the
|
||||
// TOTP step, and is revocable per-row.
|
||||
|
||||
// Insert a new trusted-device row. expiresAt is a JS Date (or ms epoch). last_used_at
|
||||
// is seeded to now (the device was just trusted at a successful login).
|
||||
async function insert({ userId, tokenHash, platform = 'web', deviceName = null, deviceHash = null, userAgent = null, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO trusted_devices (user_id, token_hash, platform, device_name, device_hash, user_agent, expires_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
|
||||
[userId, tokenHash, platform, deviceName, deviceHash, userAgent, new Date(expiresAt)],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Look up a trusted device by token hash only if it is still usable: not revoked
|
||||
// and not past expiry. Returns the row (incl. user_id) or null. Used by the login
|
||||
// path to decide whether TOTP can be skipped.
|
||||
async function findValidByHash(tokenHash) {
|
||||
const rows = await query(
|
||||
`SELECT * FROM trusted_devices
|
||||
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[tokenHash],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Stamp last_used_at when a device's trust is honored at login. Idempotent.
|
||||
async function touchLastUsed(id) {
|
||||
const res = await query(
|
||||
'UPDATE trusted_devices SET last_used_at = NOW() WHERE id = ?',
|
||||
[id],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// List a user's currently-active (unrevoked, unexpired) trusted devices — newest
|
||||
// first. Never returns the token hash. Powers the self-service "Trusted Devices"
|
||||
// list and the admin per-user view. Works for any user id (self or admin target).
|
||||
async function listActiveForUser(userId) {
|
||||
return query(
|
||||
`SELECT id, platform, device_name, device_hash, user_agent, created_at, last_used_at, expires_at
|
||||
FROM trusted_devices
|
||||
WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY last_used_at DESC, created_at DESC`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
// Count a user's currently-active trusted devices. Used to enforce the per-user cap
|
||||
// (no silent pruning — the caller refuses an over-cap insert instead).
|
||||
async function countActiveForUser(userId) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n FROM trusted_devices WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()',
|
||||
[userId],
|
||||
)
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
// Revoke one of a user's trusted devices by row id (ownership-scoped, so both self
|
||||
// and admin-for-target go through the same guarded query). Idempotent; returns
|
||||
// rows changed.
|
||||
async function revokeByIdForUser(id, userId) {
|
||||
const res = await query(
|
||||
'UPDATE trusted_devices SET revoked_at = NOW() WHERE id = ? AND user_id = ? AND revoked_at IS NULL',
|
||||
[id, userId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Revoke every active trusted device for a user ("untrust everywhere", and the
|
||||
// invalidation hook on password change/reset / TOTP disable). Returns rows changed.
|
||||
async function revokeAllForUser(userId) {
|
||||
const res = await query(
|
||||
'UPDATE trusted_devices SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL',
|
||||
[userId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Housekeeping: delete rows that are long dead (expired or revoked). Returns rows
|
||||
// removed. Same opportunistic-prune approach as mobile_refresh_tokens.
|
||||
async function pruneExpired() {
|
||||
const res = await query(
|
||||
'DELETE FROM trusted_devices WHERE expires_at < NOW() OR revoked_at IS NOT NULL',
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insert,
|
||||
findValidByHash,
|
||||
touchLastUsed,
|
||||
listActiveForUser,
|
||||
countActiveForUser,
|
||||
revokeByIdForUser,
|
||||
revokeAllForUser,
|
||||
pruneExpired,
|
||||
}
|
||||
69
server/src/model/trustedDevices/trustedDevices.model.js
Normal file
69
server/src/model/trustedDevices/trustedDevices.model.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Trusted-device store. Thin logic layer over trustedDevices.db — mirrors the
|
||||
// mobileSessions model split (.db = SQL, .model = the API the rest of the app
|
||||
// calls). The opaque trust token lives client-side; only its sha256 hash is
|
||||
// persisted (hashing is done by the session service so caller + store agree, the
|
||||
// same seam as mobile refresh tokens).
|
||||
|
||||
const db = require('./trustedDevices.db')
|
||||
|
||||
// Max active trusted devices per user. Enforced by assertUnderCap (no silent
|
||||
// pruning — an over-cap trust attempt is refused so the client can prompt the user
|
||||
// to revoke one first). See docs/website/TRUSTED_DEVICES_MFA.md §5.
|
||||
const MAX_TRUSTED_DEVICES = Number(process.env.MAX_TRUSTED_DEVICES) || 10
|
||||
|
||||
// Persist a newly trusted device (by token hash). Returns the row id.
|
||||
async function store({ userId, tokenHash, platform, deviceName, deviceHash, userAgent, expiresAt }) {
|
||||
return db.insert({ userId, tokenHash, platform, deviceName, deviceHash, userAgent, expiresAt })
|
||||
}
|
||||
|
||||
// Return the stored row for a still-valid (unrevoked, unexpired) trust token, else
|
||||
// null. Used by the login path to decide whether the TOTP step can be skipped.
|
||||
async function findValidByHash(tokenHash) {
|
||||
return db.findValidByHash(tokenHash)
|
||||
}
|
||||
|
||||
// Stamp last_used_at when a device's trust is honored at login.
|
||||
async function touchLastUsed(id) {
|
||||
return db.touchLastUsed(id)
|
||||
}
|
||||
|
||||
// List a user's active trusted devices (self-service list + admin per-user view).
|
||||
async function listActiveForUser(userId) {
|
||||
return db.listActiveForUser(userId)
|
||||
}
|
||||
|
||||
// True if the user is at/over the trusted-device cap. Callers refuse the insert and
|
||||
// signal the client to revoke one first, rather than pruning silently.
|
||||
async function isAtCap(userId) {
|
||||
const n = await db.countActiveForUser(userId)
|
||||
return n >= MAX_TRUSTED_DEVICES
|
||||
}
|
||||
|
||||
// Revoke one of a user's trusted devices by row id (ownership-scoped). Returns rows
|
||||
// changed (0 if it wasn't theirs / already gone — treat idempotently).
|
||||
async function revokeByIdForUser(id, userId) {
|
||||
return db.revokeByIdForUser(id, userId)
|
||||
}
|
||||
|
||||
// Revoke all of a user's trusted devices ("untrust everywhere" + the invalidation
|
||||
// hook on password change/reset / TOTP disable). Returns rows changed.
|
||||
async function revokeAllForUser(userId) {
|
||||
return db.revokeAllForUser(userId)
|
||||
}
|
||||
|
||||
// Drop expired/revoked rows.
|
||||
async function pruneExpired() {
|
||||
return db.pruneExpired()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_TRUSTED_DEVICES,
|
||||
store,
|
||||
findValidByHash,
|
||||
touchLastUsed,
|
||||
listActiveForUser,
|
||||
isAtCap,
|
||||
revokeByIdForUser,
|
||||
revokeAllForUser,
|
||||
pruneExpired,
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
const db = require('./uoLinkConfig.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1
|
||||
// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
|
||||
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
|
||||
119
server/src/router/cspReport.controller.js
Normal file
119
server/src/router/cspReport.controller.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// ── POST /api/csp-report — Content-Security-Policy violation sink ─────────────
|
||||
//
|
||||
// The target policy ships on Content-Security-Policy-Report-Only for one release
|
||||
// before it is enforced (docs/website/API_V2_PLAN.md § Phase 1). That soak is only
|
||||
// worth anything if the reports land somewhere a human reads, so `report-to` /
|
||||
// `report-uri` point here (see config/csp.js) and this writes them to the `csp` log
|
||||
// tag. It is deliberately same-origin: reports describe attacks against this site
|
||||
// and must not be handed to a third-party collector.
|
||||
//
|
||||
// This is an unauthenticated public POST — browsers send reports with no session and
|
||||
// no CSRF token, and gating it would silence exactly the anonymous visitors whose
|
||||
// pages we most want to hear about. So treat every field as hostile:
|
||||
// • the body is parsed under a small cap (browsers send a few KB),
|
||||
// • the rate limiter blunts a flood, since each accepted report writes a log line,
|
||||
// • every logged field is truncated, and only a fixed allowlist of fields is read.
|
||||
// Nothing is echoed back and nothing is persisted to the database.
|
||||
//
|
||||
// Retiring this: when the tightened policy flips to enforced and the report-only
|
||||
// twin is removed, this endpoint goes with it — unless a `report-to` group is kept
|
||||
// on the enforced policy, which is a reasonable thing to want.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const log = require('../utils/logger')('csp')
|
||||
|
||||
// Browsers send a few KB at most. A cap this low means a junk POST is rejected by
|
||||
// the parser before any of this code runs.
|
||||
const BODY_LIMIT = '16kb'
|
||||
|
||||
// Keep log lines bounded: `script-sample` in particular is attacker-influenced and
|
||||
// can carry a whole inline script.
|
||||
const MAX_FIELD = 200
|
||||
const clip = (value) => {
|
||||
if (value == null) return undefined
|
||||
const s = String(value)
|
||||
return s.length > MAX_FIELD ? `${s.slice(0, MAX_FIELD)}…` : s
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the two wire formats into one shape.
|
||||
*
|
||||
* `report-uri` (Firefox, Safari) POSTs `application/csp-report` with a single
|
||||
* `{ "csp-report": { … } }` object and hyphenated keys. `report-to` (Chrome) POSTs
|
||||
* `application/reports+json` with an *array* of envelopes whose `body` uses camelCase
|
||||
* keys. Reading only one of them would silently drop half the browsers.
|
||||
*/
|
||||
function normalize(body) {
|
||||
if (Array.isArray(body)) {
|
||||
return body
|
||||
.filter((entry) => entry && entry.type === 'csp-violation' && entry.body)
|
||||
.map((entry) => ({
|
||||
documentUrl: entry.body.documentURL || entry.url,
|
||||
directive: entry.body.effectiveDirective,
|
||||
blockedUrl: entry.body.blockedURL,
|
||||
disposition: entry.body.disposition,
|
||||
sample: entry.body.sample,
|
||||
}))
|
||||
}
|
||||
if (body && typeof body === 'object' && body['csp-report']) {
|
||||
const r = body['csp-report']
|
||||
return [
|
||||
{
|
||||
documentUrl: r['document-uri'],
|
||||
directive: r['effective-directive'] || r['violated-directive'],
|
||||
blockedUrl: r['blocked-uri'],
|
||||
disposition: r.disposition,
|
||||
sample: r['script-sample'],
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// POST /api/csp-report
|
||||
function receive(req, res) {
|
||||
/* #swagger.tags = ['Health']
|
||||
#swagger.summary = 'Content-Security-Policy violation report sink'
|
||||
#swagger.description = 'Receives CSP violation reports from browsers (both the `report-uri` `application/csp-report` format and the Reporting API `application/reports+json` format). Unauthenticated by necessity — browsers send reports with no session. Reports are logged, never stored or echoed. Always answers 204.'
|
||||
#swagger.security = []
|
||||
#swagger.responses[204] = { description: 'Report accepted (or ignored). No content.' }
|
||||
#swagger.responses[429] = { description: 'Too many reports from this address.' }
|
||||
*/
|
||||
|
||||
// 204 regardless of what arrived. A browser cannot act on an error here, and a
|
||||
// non-2xx would only make it retry or log noise in the user's console.
|
||||
for (const v of normalize(req.body)) {
|
||||
if (!v.directive) continue
|
||||
log.warn('csp violation', {
|
||||
// 'report' = the report-only policy fired (expected during the soak);
|
||||
// 'enforce' = the live policy actually blocked something.
|
||||
disposition: clip(v.disposition) || 'report',
|
||||
directive: clip(v.directive),
|
||||
blocked: clip(v.blockedUrl),
|
||||
document: clip(v.documentUrl),
|
||||
sample: clip(v.sample),
|
||||
ip: req.ip,
|
||||
})
|
||||
}
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
/**
|
||||
* The full middleware chain for the endpoint. Both content types get their own
|
||||
* parser because express.json() only matches `application/json` by default, and a
|
||||
* report that arrives unparsed is a report silently discarded.
|
||||
*/
|
||||
const parsers = [
|
||||
express.json({ limit: BODY_LIMIT, type: 'application/csp-report' }),
|
||||
express.json({ limit: BODY_LIMIT, type: 'application/reports+json' }),
|
||||
express.json({ limit: BODY_LIMIT }),
|
||||
// Swallow malformed / oversized bodies here rather than letting them reach the
|
||||
// global error handler, which would answer 400 and write an ERROR line quoting the
|
||||
// junk — turning "POST garbage at the open endpoint" into a log-flood primitive.
|
||||
// A browser has nothing useful to do with a 4xx from a report sink anyway.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(err, req, res, next) => res.status(204).end(),
|
||||
]
|
||||
|
||||
module.exports = { receive, parsers, normalize }
|
||||
@@ -6,8 +6,11 @@ const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const { setAuthCookie } = require('../../../auth/token')
|
||||
const { establishTrust } = require('../auth/trustDevice.helper')
|
||||
const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token')
|
||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
const loginProtection = require('../../../middleware/loginProtection')
|
||||
const botScore = require('../../../middleware/botScore')
|
||||
@@ -108,6 +111,12 @@ async function changePassword(req, res) {
|
||||
if (session && session.createdAt) {
|
||||
await users.setSessionCutoff(req.user.id, new Date(session.createdAt - 1000))
|
||||
}
|
||||
// A password change is a security event: drop every trusted device and every
|
||||
// recovery code so a compromised-then-changed account can't be re-entered with
|
||||
// a stale second-factor bypass. Clear this browser's trust cookie too.
|
||||
await trustedDevices.revokeAllForUser(req.user.id)
|
||||
await recoveryCodes.clearForUser(req.user.id)
|
||||
clearTrustCookie(req, res)
|
||||
await activity.log({ req, action: 'account.password.change' })
|
||||
log.info('account password changed', { id: req.user.id })
|
||||
return res.json({ ok: true })
|
||||
@@ -150,9 +159,14 @@ async function totpEnable(req, res) {
|
||||
return res.status(400).json({ message: 'That code is not valid. Try again.' })
|
||||
}
|
||||
await users.enableTotp(user.id)
|
||||
// Issue the initial batch of single-use recovery codes, shown to the user ONCE
|
||||
// right here (the only time they leave the server in the clear). Generation
|
||||
// replaces any prior set, so re-enrolling always starts clean.
|
||||
const codes = await recoveryCodes.generateForUser(user.id)
|
||||
await activity.log({ req, action: 'account.totp.enable' })
|
||||
await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
|
||||
log.info('totp enabled', { id: user.id, username: user.username })
|
||||
return res.json({ totp_enabled: true })
|
||||
return res.json({ totp_enabled: true, recoveryCodes: codes })
|
||||
} catch (err) {
|
||||
log.error('totpEnable', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -171,6 +185,11 @@ async function totpDisable(req, res) {
|
||||
return res.status(400).json({ message: 'That code is not valid. Try again.' })
|
||||
}
|
||||
await users.disableTotp(user.id)
|
||||
// With 2FA off, both the trusted-device bypass and recovery codes are moot and
|
||||
// must not linger — drop them so re-enabling later starts from a clean slate.
|
||||
await trustedDevices.revokeAllForUser(user.id)
|
||||
await recoveryCodes.clearForUser(user.id)
|
||||
clearTrustCookie(req, res)
|
||||
await activity.log({ req, action: 'account.totp.disable' })
|
||||
log.info('totp disabled', { id: user.id, username: user.username })
|
||||
return res.json({ totp_enabled: false })
|
||||
@@ -246,6 +265,129 @@ async function revokeSession(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trusted devices (self-service) ─────────────────────────────────────────
|
||||
// Shape a trusted_devices row for the client (never the token hash).
|
||||
function toTrustedDevice(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
platform: r.platform,
|
||||
deviceName: r.device_name || null,
|
||||
userAgent: r.user_agent || null,
|
||||
createdAt: r.created_at,
|
||||
lastUsedAt: r.last_used_at || r.created_at,
|
||||
expiresAt: r.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
// List the current user's active trusted devices (Trusted Devices screen).
|
||||
async function listTrustedDevices(req, res) {
|
||||
try {
|
||||
const rows = await trustedDevices.listActiveForUser(req.user.id)
|
||||
return res.json(rows.map(toTrustedDevice))
|
||||
} catch (err) {
|
||||
log.error('listTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Trust the CURRENT device/browser from an authenticated session. This is the
|
||||
// "revoke one, then retry" completion after a cap-reached prompt, and a general
|
||||
// self-service way to trust the device you're on. Web receives the token as the
|
||||
// httpOnly rg_trust cookie; native (bearer) sessions get it in the JSON body.
|
||||
async function trustThisDevice(req, res) {
|
||||
try {
|
||||
const isMobile = (req.session?.authMethod || req.authMethod) === 'mobile'
|
||||
const result = await establishTrust(req, req.user, {
|
||||
platform: isMobile ? 'mobile' : 'web',
|
||||
deviceName: req.body.deviceName || null,
|
||||
})
|
||||
if (!result.ok && result.capReached) {
|
||||
return res.status(409).json({ error: 'trusted_device_limit', devices: result.devices.map(toTrustedDevice) })
|
||||
}
|
||||
if (!isMobile) {
|
||||
setTrustCookie(req, res, result.trustToken)
|
||||
return res.json({ trusted: true })
|
||||
}
|
||||
return res.json({ trusted: true, trustToken: result.trustToken })
|
||||
} catch (err) {
|
||||
log.error('trustThisDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke one of the current user's trusted devices by id (ownership-scoped).
|
||||
async function revokeTrustedDevice(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const n = await trustedDevices.revokeByIdForUser(id, req.user.id)
|
||||
if (n) {
|
||||
await activity.log({ req, action: 'account.trusted_device.revoke', detail: { deviceId: id } })
|
||||
log.info('trusted device revoked (self)', { id, userId: req.user.id })
|
||||
}
|
||||
return res.json({ revoked: n > 0 })
|
||||
} catch (err) {
|
||||
log.error('revokeTrustedDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke ALL of the current user's trusted devices ("untrust everywhere"), and
|
||||
// clear this browser's trust cookie.
|
||||
async function revokeAllTrustedDevices(req, res) {
|
||||
try {
|
||||
const n = await trustedDevices.revokeAllForUser(req.user.id)
|
||||
clearTrustCookie(req, res)
|
||||
await activity.log({ req, action: 'account.trusted_device.revoke_all', detail: { count: n } })
|
||||
log.info('all trusted devices revoked (self)', { userId: req.user.id, count: n })
|
||||
return res.json({ revoked: n })
|
||||
} catch (err) {
|
||||
log.error('revokeAllTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recovery codes (self-service) ──────────────────────────────────────────
|
||||
// Remaining (unused) code count — never the codes themselves.
|
||||
async function recoveryCodesStatus(req, res) {
|
||||
try {
|
||||
const remaining = await recoveryCodes.remainingForUser(req.user.id)
|
||||
return res.json({ remaining })
|
||||
} catch (err) {
|
||||
log.error('recoveryCodesStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate the recovery-code set, returning the new codes ONCE. Password
|
||||
// step-up: an account that has a password must supply and match currentPassword
|
||||
// (SSO-only accounts with no password may proceed while authenticated, mirroring
|
||||
// changePassword). Refuses when 2FA is off (codes only exist alongside TOTP).
|
||||
async function generateRecoveryCodes(req, res) {
|
||||
try {
|
||||
const raw = await users.getRawById(req.user.id)
|
||||
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
||||
if (!raw.totp_enabled) {
|
||||
return res.status(400).json({ message: 'Enable two-factor before generating recovery codes.' })
|
||||
}
|
||||
if (raw.password_hash) {
|
||||
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
|
||||
if (!ok) {
|
||||
loginProtection.recordFailure(req.ip)
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
log.warn('generateRecoveryCodes wrong current password', { id: req.user.id, ip: req.ip })
|
||||
return res.status(400).json({ message: 'Your current password is incorrect.' })
|
||||
}
|
||||
}
|
||||
const codes = await recoveryCodes.generateForUser(req.user.id)
|
||||
await activity.log({ req, action: 'account.recovery_codes.generate', detail: { count: codes.length } })
|
||||
log.info('recovery codes regenerated', { id: req.user.id })
|
||||
return res.json({ recoveryCodes: codes })
|
||||
} catch (err) {
|
||||
log.error('generateRecoveryCodes', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccount,
|
||||
changeUsername,
|
||||
@@ -257,4 +399,10 @@ module.exports = {
|
||||
unlinkIdentity,
|
||||
listSessions,
|
||||
revokeSession,
|
||||
listTrustedDevices,
|
||||
trustThisDevice,
|
||||
revokeTrustedDevice,
|
||||
revokeAllTrustedDevices,
|
||||
recoveryCodesStatus,
|
||||
generateRecoveryCodes,
|
||||
}
|
||||
|
||||
87
server/src/router/v1/admin/account.router.js
Normal file
87
server/src/router/v1/admin/account.router.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// Admin · Account — self-service account security for staff.
|
||||
//
|
||||
// Mounted at /api/v1/admin/account by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Deliberately NOT behind adminOnly: an editor
|
||||
// or moderator manages their own 2FA and linked identities here, exactly as a
|
||||
// player does under /player. Every handler keys off req.user.id.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const account = require('./account.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const accountRouter = express.Router()
|
||||
|
||||
accountRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Get the current account (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
account.getAccount,
|
||||
)
|
||||
accountRouter.post(
|
||||
'/totp/setup',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
account.totpSetup,
|
||||
)
|
||||
accountRouter.post(
|
||||
'/totp/enable',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Enable 2FA by confirming a code'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||
validate,
|
||||
account.totpEnable,
|
||||
)
|
||||
accountRouter.post(
|
||||
'/totp/disable',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Disable 2FA by confirming a code'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||
validate,
|
||||
account.totpDisable,
|
||||
)
|
||||
|
||||
// Linked SSO identities (self-service — any logged-in role manages their own).
|
||||
accountRouter.get(
|
||||
'/identities',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'List linked SSO identities (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
account.listIdentities,
|
||||
)
|
||||
accountRouter.delete(
|
||||
'/identities/:provider',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('provider').matches(/^[a-z0-9-]+$/),
|
||||
validate,
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
module.exports = accountRouter
|
||||
28
server/src/router/v1/admin/activity.router.js
Normal file
28
server/src/router/v1/admin/activity.router.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Admin · Activity — the staff audit log.
|
||||
//
|
||||
// Mounted at /api/v1/admin/activity by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate: any staff member may read the
|
||||
// log, and every staff action is written to it regardless of who took it.
|
||||
//
|
||||
// A one-route capability, but a distinct one — this is the audit trail, not the
|
||||
// dashboard's stats overview and not the bot-scoring state under /bot-activity.
|
||||
// Handlers still live in admin.controller.js; this re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
|
||||
const activityRouter = express.Router()
|
||||
|
||||
activityRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Activity']
|
||||
// #swagger.summary = 'List recent admin activity'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows to return.' }
|
||||
/* #swagger.responses[200] = { description: 'Activity entries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listActivity,
|
||||
)
|
||||
|
||||
module.exports = activityRouter
|
||||
@@ -3,6 +3,8 @@ const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
@@ -651,6 +653,88 @@ async function deleteUser(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: a user's trusted devices & MFA (admin only) ─────────────────────
|
||||
// Staff-facing view/revocation of another user's trusted devices, plus an MFA
|
||||
// reset for a locked-out user. All actions are audit-logged with the acting admin
|
||||
// (via activity.log's req) and the target user id.
|
||||
function toAdminTrustedDevice(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
platform: r.platform,
|
||||
deviceName: r.device_name || null,
|
||||
userAgent: r.user_agent || null,
|
||||
createdAt: r.created_at,
|
||||
lastUsedAt: r.last_used_at || r.created_at,
|
||||
expiresAt: r.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listUserTrustedDevices(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
const rows = await trustedDevices.listActiveForUser(id)
|
||||
return res.json(rows.map(toAdminTrustedDevice))
|
||||
} catch (err) {
|
||||
log.error('listUserTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeUserTrustedDevice(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
const deviceId = Number(req.params.deviceId)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
const n = await trustedDevices.revokeByIdForUser(deviceId, id)
|
||||
if (n) {
|
||||
await activity.log({ req, action: 'admin.trusted_device.revoke', detail: { userId: id, deviceId } })
|
||||
log.info('admin revoked trusted device', { adminId: req.user.id, userId: id, deviceId })
|
||||
}
|
||||
return res.json({ revoked: n > 0 })
|
||||
} catch (err) {
|
||||
log.error('revokeUserTrustedDevice', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAllUserTrustedDevices(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
const n = await trustedDevices.revokeAllForUser(id)
|
||||
await activity.log({ req, action: 'admin.trusted_device.revoke_all', detail: { userId: id, count: n } })
|
||||
log.info('admin revoked all trusted devices', { adminId: req.user.id, userId: id, count: n })
|
||||
return res.json({ revoked: n })
|
||||
} catch (err) {
|
||||
log.error('revokeAllUserTrustedDevices', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Reset a locked-out user's MFA: turn TOTP off, drop every trusted device, and
|
||||
// clear their recovery codes. Lets an admin recover a user who lost their
|
||||
// authenticator; the user can then sign in with their password alone and re-enroll.
|
||||
async function resetUserMfa(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const target = await users.getById(id)
|
||||
if (!target) return res.status(404).json({ message: 'Not found' })
|
||||
await users.disableTotp(id)
|
||||
await trustedDevices.revokeAllForUser(id)
|
||||
await recoveryCodes.clearForUser(id)
|
||||
await activity.log({ req, action: 'admin.user.totp.reset', detail: { userId: id } })
|
||||
log.info('admin reset user MFA', { adminId: req.user.id, userId: id })
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
log.error('resetUserMfa', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dashboard,
|
||||
setSiteMode,
|
||||
@@ -685,4 +769,8 @@ module.exports = {
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
listUserTrustedDevices,
|
||||
revokeUserTrustedDevice,
|
||||
revokeAllUserTrustedDevices,
|
||||
resetUserMfa,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
102
server/src/router/v1/admin/authProviders.router.js
Normal file
102
server/src/router/v1/admin/authProviders.router.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// Admin · Auth Providers — SSO/OAuth2/OIDC provider configuration.
|
||||
//
|
||||
// Mounted at /api/v1/admin/auth by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`; the routes below are /providers under that,
|
||||
// so the emitted URLs stay /api/v1/admin/auth/providers[/:id].
|
||||
//
|
||||
// Admin-only: these rows carry client secrets (write-only, AES-GCM at rest via
|
||||
// utils/secretBox.js) and decide which external identities may sign in at all.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const authProviders = require('./authProviders.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const providersRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
providersRouter.get(
|
||||
'/providers',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'List configured SSO providers (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
authProviders.list,
|
||||
)
|
||||
providersRouter.post(
|
||||
'/providers',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'Create a custom SSO provider (admin only)'
|
||||
// #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('id').matches(/^[a-z0-9-]+$/),
|
||||
body('kind').isIn(['oidc', 'oauth2']),
|
||||
body('name').isString().trim().notEmpty().isLength({ max: 80 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
body('clientId').optional({ values: 'falsy' }).isString(),
|
||||
body('secret').optional({ values: 'falsy' }).isString(),
|
||||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||||
body('priority').optional().isInt(),
|
||||
validate,
|
||||
authProviders.create,
|
||||
)
|
||||
providersRouter.put(
|
||||
'/providers/:id',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'Update an SSO provider (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').matches(/^[a-z0-9-]+$/),
|
||||
body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
body('clientId').optional({ values: 'falsy' }).isString(),
|
||||
body('secret').optional({ values: 'falsy' }).isString(),
|
||||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||||
body('priority').optional().isInt(),
|
||||
validate,
|
||||
authProviders.update,
|
||||
)
|
||||
providersRouter.delete(
|
||||
'/providers/:id',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'Delete a custom SSO provider (admin only)'
|
||||
// #swagger.description = 'Built-in providers cannot be deleted — disable them instead.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').matches(/^[a-z0-9-]+$/),
|
||||
validate,
|
||||
authProviders.remove,
|
||||
)
|
||||
|
||||
module.exports = providersRouter
|
||||
@@ -1,7 +1,7 @@
|
||||
// Bot-scoring / IP-ban visibility for admins. Read-only view of the botScore
|
||||
// middleware's in-memory state plus a recent-events feed, and a single mutating
|
||||
// action — an emergency unban for false positives. Mounted behind the admin-only
|
||||
// RBAC gate (see admin.routes.js). This is visibility + emergency unban only;
|
||||
// RBAC gate (see botActivity.router.js). This is visibility + emergency unban only;
|
||||
// there is deliberately no way to add a ban or change scoring weights from here.
|
||||
|
||||
const botScore = require('../../../middleware/botScore')
|
||||
|
||||
47
server/src/router/v1/admin/botActivity.router.js
Normal file
47
server/src/router/v1/admin/botActivity.router.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Admin · Bot Activity — the botScore middleware's scoring/ban state.
|
||||
//
|
||||
// Mounted at /api/v1/admin/bot-activity by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Read-only view of the in-memory scores,
|
||||
// banned IPs and recent events, plus an emergency unban for false positives.
|
||||
//
|
||||
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
||||
// the middleware chain each route carries is unchanged by the move.
|
||||
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
|
||||
const botActivity = require('./botActivity.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const botActivityRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
botActivityRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Bot Activity']
|
||||
// #swagger.summary = 'Bot-scoring / ban state and recent events (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Banned IPs, scores and recent events', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
botActivity.getBotActivity,
|
||||
)
|
||||
botActivityRouter.post(
|
||||
'/unban',
|
||||
// #swagger.tags = ['Admin · Bot Activity']
|
||||
// #swagger.summary = 'Emergency unban an IP (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Unbanned (echoes the ip and whether an entry was cleared)', content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('ip').isIP(),
|
||||
validate,
|
||||
botActivity.unbanIp,
|
||||
)
|
||||
|
||||
module.exports = botActivityRouter
|
||||
58
server/src/router/v1/admin/dashboard.router.js
Normal file
58
server/src/router/v1/admin/dashboard.router.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Admin · Dashboard — the landing summary, plus the /site-mode singleton.
|
||||
//
|
||||
// Mounted at the ROOT of /api/v1/admin by admin/index.js (not at a prefix),
|
||||
// which already applied `noindex, isLoggedIn, staffOnly`. Two singleton URLs
|
||||
// that share a swagger tag and a screen but not a path segment live together
|
||||
// here rather than in two one-route files, which is what the target tree in
|
||||
// docs/website/API_V2_PLAN.md § Phase 2 calls for.
|
||||
//
|
||||
// A root mount is the one place the split's "always mount at a prefix" rule is
|
||||
// relaxed, and it is safe ONLY because this file declares no router-level
|
||||
// middleware: a bare `use(gate)` here would run for every request passing
|
||||
// through toward another mount and 403 an editor on an unrelated route. Keep
|
||||
// gates per-route in this file.
|
||||
//
|
||||
// GET /dashboard — stats overview, any staff role.
|
||||
// PUT /site-mode — live ↔ maintenance, admin only.
|
||||
//
|
||||
// Neither is the audit log (/activity) nor the bot-scoring state
|
||||
// (/bot-activity); those are separate capabilities that read alike. Handlers
|
||||
// still live in admin.controller.js; this re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const dashboardRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
dashboardRouter.get(
|
||||
'/dashboard',
|
||||
// #swagger.tags = ['Admin · Dashboard']
|
||||
// #swagger.summary = 'Dashboard summary counts'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.dashboard,
|
||||
)
|
||||
dashboardRouter.put(
|
||||
'/site-mode',
|
||||
// #swagger.tags = ['Admin · Dashboard']
|
||||
// #swagger.summary = 'Set site mode (admin only)'
|
||||
// #swagger.description = 'Switch the site between live and maintenance.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeState" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('mode').isIn(['live', 'maintenance']),
|
||||
validate,
|
||||
ctrl.setSiteMode,
|
||||
)
|
||||
|
||||
module.exports = dashboardRouter
|
||||
51
server/src/router/v1/admin/discordBot.router.js
Normal file
51
server/src/router/v1/admin/discordBot.router.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// Admin · Discord Bot — control plane for the bot process.
|
||||
//
|
||||
// Mounted at /api/v1/admin/discord-bot by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. The bot token is entered and enabled here,
|
||||
// never through an env var, and is write-only over this API (SECURITY note in
|
||||
// discordBot.controller.js).
|
||||
//
|
||||
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
||||
// the middleware chain each route carries is unchanged by the move.
|
||||
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
|
||||
const discordBot = require('./discordBot.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const discordBotRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
discordBotRouter.get(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Discord Bot']
|
||||
// #swagger.summary = 'Get Discord bot config + live status (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Masked config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
discordBot.getConfig,
|
||||
)
|
||||
discordBotRouter.put(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Discord Bot']
|
||||
// #swagger.summary = 'Save Discord bot config (admin only)'
|
||||
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one unchanged.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { guildId: { type: "string" }, token: { type: "string" }, enabled: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, invalid token, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('guildId').optional({ values: 'falsy' }).isString().trim(),
|
||||
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||||
body('enabled').optional().isBoolean(),
|
||||
validate,
|
||||
discordBot.saveConfig,
|
||||
)
|
||||
|
||||
module.exports = discordBotRouter
|
||||
96
server/src/router/v1/admin/email.router.js
Normal file
96
server/src/router/v1/admin/email.router.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// Admin · Email — outbound mail delivery via Gmail OAuth2.
|
||||
//
|
||||
// Mounted at /api/v1/admin/email by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. The modern replacement for env SMTP: the
|
||||
// refresh token is captured by the connect flow below and is write-only over
|
||||
// this API (stored encrypted by utils/secretBox.js, never returned).
|
||||
//
|
||||
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
||||
// the middleware chain each route carries is unchanged by the move.
|
||||
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
|
||||
const emailConfig = require('./emailConfig.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const emailRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
emailRouter.get(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Get email delivery config + status (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
emailConfig.getConfig,
|
||||
)
|
||||
emailRouter.put(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Update email delivery config (admin only)'
|
||||
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
validate,
|
||||
emailConfig.saveConfig,
|
||||
)
|
||||
emailRouter.get(
|
||||
'/connect/start',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
|
||||
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
emailConfig.connectStart,
|
||||
)
|
||||
emailRouter.get(
|
||||
'/connect/callback',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
|
||||
adminOnly,
|
||||
emailConfig.connectCallback,
|
||||
)
|
||||
emailRouter.post(
|
||||
'/test',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Send a test email (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[502] = { description: 'Send failed / not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('to').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||||
validate,
|
||||
emailConfig.testSend,
|
||||
)
|
||||
emailRouter.post(
|
||||
'/disconnect',
|
||||
// #swagger.tags = ['Admin · Email']
|
||||
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
emailConfig.disconnect,
|
||||
)
|
||||
|
||||
module.exports = emailRouter
|
||||
49
server/src/router/v1/admin/imageUpload.js
Normal file
49
server/src/router/v1/admin/imageUpload.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Shared multer middleware for the two admin image-upload routes:
|
||||
// POST /admin/posts/upload (posts.router.js) and POST /admin/uploads
|
||||
// (uploads.router.js). It lived inline in admin.routes.js while both routes did;
|
||||
// the PR 3 split put them in different files, so the config moved here rather
|
||||
// than being duplicated — one upload directory, one mimetype allowlist.
|
||||
//
|
||||
// Kept in this directory on purpose: UPLOAD_DIR is resolved relative to
|
||||
// __dirname, so moving the file to another folder would silently repoint the
|
||||
// upload directory.
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const crypto = require('crypto')
|
||||
const multer = require('multer')
|
||||
|
||||
const UPLOAD_DIR =
|
||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
|
||||
// Whitelisted image mimetypes → the extension we store the file under. The
|
||||
// stored extension is derived from this map (keyed by the accepted mimetype),
|
||||
// never from originalname — so a spoofed `Content-Type: image/png` paired with
|
||||
// `originalname: x.html` can never land an executable .html file in /uploads.
|
||||
const MIME_EXT = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/avif': '.avif',
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = MIME_EXT[file.mimetype] || ''
|
||||
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`)
|
||||
},
|
||||
})
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Single source of truth: only mimetypes we can map to a safe extension pass.
|
||||
if (MIME_EXT[file.mimetype]) cb(null, true)
|
||||
else cb(new Error('Only image uploads are allowed'))
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = { upload, UPLOAD_DIR, MIME_EXT }
|
||||
83
server/src/router/v1/admin/index.js
Normal file
83
server/src/router/v1/admin/index.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// /api/v1/admin — the admin surface, assembled from per-capability routers.
|
||||
//
|
||||
// This file owns exactly two things: the gate every admin route shares, and the
|
||||
// mount table. No route is declared here. Each capability router mounts at the
|
||||
// prefix it already owned inside the old monolithic admin.routes.js, so the
|
||||
// emitted URL set is byte-identical — proved per PR by a zero-line diff in
|
||||
// server/routes.manifest.json (`npm run routes:manifest`).
|
||||
//
|
||||
// The admin group is fully split as of PR 4: admin.routes.js is gone and every
|
||||
// one of the 110 admin routes is declared in a capability router below.
|
||||
//
|
||||
// See docs/website/API_V2_PLAN.md § Phase 2 for the split.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
|
||||
const accountRouter = require('./account.router')
|
||||
const usersRouter = require('./users.router')
|
||||
const invitesRouter = require('./invites.router')
|
||||
const authProvidersRouter = require('./authProviders.router')
|
||||
const moderationRouter = require('./moderation.router')
|
||||
const botActivityRouter = require('./botActivity.router')
|
||||
const activityRouter = require('./activity.router')
|
||||
const postsRouter = require('./posts.router')
|
||||
const uploadsRouter = require('./uploads.router')
|
||||
const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const shardRouter = require('./shard.router')
|
||||
const uoLinkRouter = require('./uoLink.router')
|
||||
const emailRouter = require('./email.router')
|
||||
const discordBotRouter = require('./discordBot.router')
|
||||
const settingsRouter = require('./settings.router')
|
||||
const dashboardRouter = require('./dashboard.router')
|
||||
|
||||
const adminRouter = express.Router()
|
||||
|
||||
// Every admin route requires auth, a STAFF role, and is kept out of search
|
||||
// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted
|
||||
// role: without it, the editor-tier routes below (dashboard, posts, wiki,
|
||||
// uploads) that are only guarded by isLoggedIn would be reachable by players.
|
||||
// Players get 403 here and use the self-scoped /player group instead.
|
||||
//
|
||||
// It lives here, ahead of every mount, so a capability router extracted in a
|
||||
// later PR cannot silently ship without it.
|
||||
const staffOnly = requireRole('admin', 'editor', 'moderator')
|
||||
adminRouter.use(noindex, isLoggedIn, staffOnly)
|
||||
|
||||
adminRouter.use('/account', accountRouter)
|
||||
adminRouter.use('/users', usersRouter)
|
||||
adminRouter.use('/invites', invitesRouter)
|
||||
// Mounted at /auth, not /auth/providers: /admin/auth is the capability, and the
|
||||
// routes inside read as /providers[/:id].
|
||||
adminRouter.use('/auth', authProvidersRouter)
|
||||
// /moderation carries its own moderator gate; /bot-activity is admin-only per
|
||||
// route. /activity is staff-wide — the audit log, not the bot-scoring state.
|
||||
adminRouter.use('/moderation', moderationRouter)
|
||||
adminRouter.use('/bot-activity', botActivityRouter)
|
||||
adminRouter.use('/activity', activityRouter)
|
||||
// Content, all editor-tier (no gate beyond staffOnly above). /uploads is the
|
||||
// rich-text editors' generalized upload; /posts owns its own /posts/upload.
|
||||
adminRouter.use('/posts', postsRouter)
|
||||
adminRouter.use('/uploads', uploadsRouter)
|
||||
adminRouter.use('/wiki', wikiRouter)
|
||||
adminRouter.use('/pages', pagesRouter)
|
||||
// Ops and configuration. /shard mixes tiers on one prefix — self-service game
|
||||
// account linking (no extra gate) alongside modAccess in-game staff ops — so
|
||||
// one router owns the prefix and gates per route. The rest are admin-only.
|
||||
// /admin/shard/pages is the in-game help-page queue, unrelated to /admin/pages.
|
||||
adminRouter.use('/shard', shardRouter)
|
||||
adminRouter.use('/uo-link', uoLinkRouter)
|
||||
adminRouter.use('/email', emailRouter)
|
||||
adminRouter.use('/discord-bot', discordBotRouter)
|
||||
adminRouter.use('/settings', settingsRouter)
|
||||
|
||||
// The two singletons that own no path segment of their own: GET /dashboard and
|
||||
// PUT /site-mode. Mounted at the group root, last, exactly where the residual
|
||||
// admin.routes.js used to sit — safe because dashboard.router.js declares no
|
||||
// router-level middleware, only its two routes.
|
||||
adminRouter.use('/', dashboardRouter)
|
||||
|
||||
module.exports = adminRouter
|
||||
54
server/src/router/v1/admin/invites.router.js
Normal file
54
server/src/router/v1/admin/invites.router.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Admin · Invites — create, list and revoke emailed account invites.
|
||||
//
|
||||
// Mounted at /api/v1/admin/invites by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Issuing an invite picks the new account's
|
||||
// role, so it is admin-only — otherwise an editor could mint an admin.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const invites = require('./invites.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const invitesRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
invitesRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
adminOnly,
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
|
||||
validate,
|
||||
invites.create,
|
||||
)
|
||||
invitesRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'List recent invites (no tokens)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
adminOnly,
|
||||
invites.list,
|
||||
)
|
||||
invitesRouter.delete(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Revoke a pending invite'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
invites.revoke,
|
||||
)
|
||||
|
||||
module.exports = invitesRouter
|
||||
@@ -1,6 +1,6 @@
|
||||
// Admin moderation dashboard (Phase 6). Read-only views over the bot's
|
||||
// mod_actions log plus server-owned staff notes. Mounted behind the
|
||||
// admin+moderator RBAC gate (see admin.routes.js). The only mutation here is
|
||||
// admin+moderator RBAC gate (see moderation.router.js). The only mutation here is
|
||||
// adding a staff note; admin_only notes are further restricted to the admin role.
|
||||
const moderation = require('../../../model/moderation/moderation.model')
|
||||
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||
|
||||
174
server/src/router/v1/admin/moderation.router.js
Normal file
174
server/src/router/v1/admin/moderation.router.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// Admin · Moderation — the moderation dashboard and the appeals queue.
|
||||
//
|
||||
// Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's
|
||||
// mod_actions log, plus staff notes and staff triage of player-submitted
|
||||
// ban/mute appeals.
|
||||
//
|
||||
// The whole capability is gated for the moderator role (admins included), so the
|
||||
// gate is a router-level `use` — exactly equivalent to the old
|
||||
// `adminRouter.use('/moderation', modAccess)` now that this router is mounted at
|
||||
// a prefix. Editors get 403 here.
|
||||
//
|
||||
// Handlers still live in moderation.controller.js; this re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const moderation = require('./moderation.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const moderationRouter = express.Router()
|
||||
const modAccess = requireRole('admin', 'moderator')
|
||||
|
||||
moderationRouter.use(modAccess)
|
||||
moderationRouter.get(
|
||||
'/stats/summary',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Moderation action counts for 24h/7d/30d (admin or moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.getSummary,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/recent',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Recent moderation actions, optionally filtered by type'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.getRecent,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/search',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Look up moderated users by Discord id or username snapshot'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.search,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/members',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Recent member join/leave events (optionally filtered by type)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.getMembers,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/filter-hits',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Recent automated content-filter hits'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.getFilterHits,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/spam-hits',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Recent automated spam-detection hits'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.getSpamHits,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/user/:discordId',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^\d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUser,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/user/:discordId/actions',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Full moderation action history for a user'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^\d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserActions,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/user/:discordId/notes',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Staff notes for a user (admin_only notes hidden from moderators)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^\d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserNotes,
|
||||
)
|
||||
moderationRouter.post(
|
||||
'/user/:discordId/notes',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Add a staff note (admin_only visibility requires the admin role)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^\d{1,32}$/),
|
||||
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
|
||||
body('visibility').optional().isIn(['staff_only', 'admin_only']),
|
||||
validate,
|
||||
moderation.addUserNote,
|
||||
)
|
||||
|
||||
// ── Appeals queue (Phase 6c) ──────────────────────────────────────────
|
||||
// Approving an appeal can trigger an automatic Discord reversal (Phase 6d) —
|
||||
// see resolveAppeal.
|
||||
moderationRouter.get(
|
||||
'/appeals',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'List moderation appeals (default: pending + under_review)'
|
||||
// #swagger.description = 'Filter with ?status=<pending|under_review|approved|denied|withdrawn> or ?status=all. Paginated with ?limit&offset.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Appeals queue', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
||||
moderation.getAppeals,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/appeals/:id',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Get a single moderation appeal'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.responses[200] = { description: 'The appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
moderation.getAppeal,
|
||||
)
|
||||
moderationRouter.post(
|
||||
'/appeals/:id/claim',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Claim a pending appeal (→ under_review)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.responses[200] = { description: 'The claimed appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is not open for claiming', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
moderation.claimAppeal,
|
||||
)
|
||||
moderationRouter.post(
|
||||
'/appeals/:id/resolve',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Resolve an appeal (approved | denied); approval may auto-reverse the Discord action'
|
||||
// #swagger.description = 'Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResolveAppealRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The resolved appeal (with reversal outcome)', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealResolveResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error (status must be approved or denied)', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('status').isIn(['approved', 'denied']),
|
||||
body('staff_response').optional({ values: 'falsy' }).isString().trim().isLength({ max: 4000 }),
|
||||
validate,
|
||||
moderation.resolveAppeal,
|
||||
)
|
||||
moderationRouter.get(
|
||||
'/user/:discordId/appeals',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Appeals submitted for a Discord user'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['discordId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Discord snowflake.' }
|
||||
/* #swagger.responses[200] = { description: 'Appeals for the user', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
||||
param('discordId').matches(/^\d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserAppeals,
|
||||
)
|
||||
|
||||
module.exports = moderationRouter
|
||||
114
server/src/router/v1/admin/pages.router.js
Normal file
114
server/src/router/v1/admin/pages.router.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// Admin · Pages — the block-based CMS page builder: drafts, protection, and
|
||||
// short-lived preview links.
|
||||
//
|
||||
// Mounted at /api/v1/admin/pages by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate — editors build pages. Note
|
||||
// that protection is *not* a role gate: POST /:id/unprotect re-verifies the
|
||||
// caller's password server-side (see pages.controller.js).
|
||||
//
|
||||
// Unrelated to /admin/shard/pages, which is the in-game help-page (support)
|
||||
// queue and stays with the shard capability.
|
||||
//
|
||||
// Handlers live in pages.controller.js; this PR re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const pagesCtrl = require('./pages.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const pagesRouter = express.Router()
|
||||
|
||||
pagesRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'List all CMS pages (summaries)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
pagesCtrl.listPages,
|
||||
)
|
||||
pagesRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Create a CMS page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('slug').isString().trim().notEmpty(),
|
||||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
validate,
|
||||
pagesCtrl.createPage,
|
||||
)
|
||||
pagesRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.getPage,
|
||||
)
|
||||
pagesRouter.patch(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
|
||||
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.updatePage,
|
||||
)
|
||||
pagesRouter.delete(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.deletePage,
|
||||
)
|
||||
pagesRouter.post(
|
||||
'/:id/unprotect',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
|
||||
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('password').isString().notEmpty(),
|
||||
validate,
|
||||
pagesCtrl.unprotectPage,
|
||||
)
|
||||
pagesRouter.post(
|
||||
'/:id/preview',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.createPreview,
|
||||
)
|
||||
|
||||
module.exports = pagesRouter
|
||||
139
server/src/router/v1/admin/posts.router.js
Normal file
139
server/src/router/v1/admin/posts.router.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus
|
||||
// the announcement pipeline (town crier + Discord) status and retry.
|
||||
//
|
||||
// Mounted at /api/v1/admin/posts by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the
|
||||
// editor tier's whole job, so admin, editor and moderator all reach these.
|
||||
//
|
||||
// Handlers still live in admin.controller.js; this PR re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const postsRouter = express.Router()
|
||||
|
||||
postsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'List all posts (including unpublished)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['category'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Optional category filter.' }
|
||||
/* #swagger.responses[200] = { description: 'Posts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Post" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listPosts,
|
||||
)
|
||||
postsRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Create a post'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('category').isString().notEmpty(),
|
||||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
validate,
|
||||
ctrl.createPost,
|
||||
)
|
||||
postsRouter.post(
|
||||
'/upload',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Upload a post image (multipart)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Stored image URL', content: { "application/json": { schema: { type: "object", properties: { image_url: { type: "string", example: "/uploads/1700000000-abcd.png" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No image / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
upload.single('image'),
|
||||
ctrl.uploadImage,
|
||||
)
|
||||
postsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Get a post by id'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.responses[200] = { description: 'The post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.getPost,
|
||||
)
|
||||
postsRouter.put(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Update a post'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.updatePost,
|
||||
)
|
||||
postsRouter.patch(
|
||||
'/:id/publish',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Publish / unpublish a post'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('published').isBoolean(),
|
||||
validate,
|
||||
ctrl.publishPost,
|
||||
)
|
||||
postsRouter.delete(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Delete a post'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.deletePost,
|
||||
)
|
||||
postsRouter.get(
|
||||
'/:id/announce',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Get the announcement pipeline status for a post'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.responses[200] = { description: 'The announce job for the post, or null if never announced', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.getAnnounceStatus,
|
||||
)
|
||||
postsRouter.post(
|
||||
'/:id/announce/retry',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('leg').isIn(['towncrier', 'discord']),
|
||||
validate,
|
||||
ctrl.retryAnnounceLeg,
|
||||
)
|
||||
|
||||
module.exports = postsRouter
|
||||
45
server/src/router/v1/admin/settings.router.js
Normal file
45
server/src/router/v1/admin/settings.router.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Admin · Settings — the site-wide key/value settings store.
|
||||
//
|
||||
// Mounted at /api/v1/admin/settings by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Editors may manage content, but settings
|
||||
// are admin-only: this store gates registration, game-account signup, the
|
||||
// contact form and the rest of the site's behaviour switches.
|
||||
//
|
||||
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
||||
// the middleware chain each route carries is unchanged by the move. Handlers
|
||||
// still live in admin.controller.js; this re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const settingsRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
settingsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Get all site settings (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'All settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.getSettings,
|
||||
)
|
||||
settingsRouter.put(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Update site settings (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.updateSettings,
|
||||
)
|
||||
|
||||
module.exports = settingsRouter
|
||||
386
server/src/router/v1/admin/shard.router.js
Normal file
386
server/src/router/v1/admin/shard.router.js
Normal file
@@ -0,0 +1,386 @@
|
||||
// Admin · Shard — everything under /api/v1/admin/shard, in two tiers.
|
||||
//
|
||||
// Mounted at /api/v1/admin/shard by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Two capabilities share this prefix, and
|
||||
// prefix ownership is the invariant the split preserves — so they share a file:
|
||||
//
|
||||
// 1. Self-service game-account linking (no extra gate). A staff member links
|
||||
// and inspects their OWN in-game account exactly as a player does under
|
||||
// /player/shard; the handlers are the very same `player/shard.controller`
|
||||
// ones, keyed off req.user.id. These keep their `Admin · Account` swagger
|
||||
// tag, which is why the tag disagrees with this filename.
|
||||
// 2. Privileged live-shard operations and the help-page queue (`modAccess` —
|
||||
// admin or moderator). `actor` is stamped server-side from the session in
|
||||
// shardOps.controller.js; the request body never carries it.
|
||||
//
|
||||
// `modAccess` stays a per-route gate rather than a router-level `use`: it was
|
||||
// per-route in admin.routes.js, and half the routes here must NOT have it.
|
||||
//
|
||||
// NOTE: /admin/shard/pages is the in-game help-page (support) queue. It is
|
||||
// unrelated to /admin/pages, the CMS page builder.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const shardVisibility = require('./shardVisibility.controller')
|
||||
const shardAtlas = require('./shardAtlas.controller')
|
||||
const shardClilocs = require('./shardClilocs.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const shardRouter = express.Router()
|
||||
|
||||
// Moderator gate. Admins can do everything a moderator can.
|
||||
const modAccess = requireRole('admin', 'moderator')
|
||||
// Admin-only gate, for settings that decide what the PUBLIC sees.
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
// ── Game account linking (self-service, any staff role) ───────────────
|
||||
// Staff link their OWN in-game account here, exactly like players do under
|
||||
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
shardRouter.post(
|
||||
'/link',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||
validate,
|
||||
selfShard.link,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/accounts',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
selfShard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/roster/:account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
selfShard.roster,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/vendors/:account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
selfShard.vendors,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/char/:serial',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
selfShard.getChar,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/sales',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
selfShard.getSales,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
|
||||
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
selfShard.createGameAccount,
|
||||
)
|
||||
|
||||
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||||
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||||
// well as admins (modAccess). `actor` is stamped server-side from the session in
|
||||
// the controller — the body never carries it. See shardOps.controller.js.
|
||||
shardRouter.post(
|
||||
'/kick',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Kick every live session of an account (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.kick,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/ban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
|
||||
body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
shardOps.ban,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/unban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Clear an account ban (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
shardOps.unban,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/broadcast',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('text').isString().trim().isLength({ min: 1, max: 300 }),
|
||||
body('hue').optional().isInt({ min: 0, max: 3000 }),
|
||||
validate,
|
||||
shardOps.broadcast,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/pages',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
modAccess,
|
||||
shardOps.listPages,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/pages/:id/respond',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('message').isString().trim().isLength({ min: 1, max: 500 }),
|
||||
body('close').optional().isBoolean(),
|
||||
validate,
|
||||
shardOps.respondPage,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/pages/:id/close',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.closePage,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/audit',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listAudit,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/houses',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
|
||||
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listHouses,
|
||||
)
|
||||
|
||||
// ── Spawn atlas (admin only) ──────────────────────────────────────────
|
||||
// Operating the atlas import. Admin-only rather than moderator: it reads a path
|
||||
// on the server's filesystem and replaces every atlas table, which is closer to
|
||||
// a deploy action than to moderation.
|
||||
//
|
||||
// These routes sit under /admin/shard even though the public ones deliberately
|
||||
// do NOT sit under /public/shard. That is not an inconsistency: the public split
|
||||
// says "this data does not come from the sidecar", while the admin panel is
|
||||
// simply part of shard administration and belongs beside the rest of it.
|
||||
shardRouter.get(
|
||||
'/atlas',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
||||
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.getStatus,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
|
||||
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
validate,
|
||||
shardAtlas.importAtlas,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/approve',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
||||
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.approve,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/reject',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
||||
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.reject,
|
||||
)
|
||||
shardRouter.put(
|
||||
'/atlas/path',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)'
|
||||
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
adminOnly,
|
||||
body('path').isString().isLength({ max: 512 }),
|
||||
validate,
|
||||
shardAtlas.setPath,
|
||||
)
|
||||
|
||||
// ── Cliloc table (admin only) ─────────────────────────────────────────────
|
||||
// UO's id → display-string map, converted once by the operator from their own
|
||||
// client (docs/website/CLILOCS.md). Sits beside the atlas for the same reason:
|
||||
// it is static content derived from operator-supplied files rather than anything
|
||||
// the sidecar sends, and operating it is shard administration.
|
||||
//
|
||||
// There is deliberately NO public counterpart. The table is never served as a
|
||||
// table — 123k rows would dwarf any page that used it, and the Android client
|
||||
// consumes the same already-resolved JSON. Names are applied server-side to the
|
||||
// responses that need them.
|
||||
shardRouter.get(
|
||||
'/clilocs',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
|
||||
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardClilocs.getStatus,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/clilocs/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
|
||||
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
body('approve').optional().isBoolean(),
|
||||
validate,
|
||||
shardClilocs.importClilocs,
|
||||
)
|
||||
shardRouter.put(
|
||||
'/clilocs/path',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Set the cliloc source the site reads from (admin only)'
|
||||
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
adminOnly,
|
||||
body('path').isString().isLength({ max: 512 }),
|
||||
validate,
|
||||
shardClilocs.setPath,
|
||||
)
|
||||
|
||||
// ── Feature visibility (admin only) ───────────────────────────────────
|
||||
// Who can see which shard surface, and which sensitive fields within it. This
|
||||
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
|
||||
shardRouter.get(
|
||||
'/visibility',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
|
||||
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardVisibility.getVisibility,
|
||||
)
|
||||
shardRouter.put(
|
||||
'/visibility',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
|
||||
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('features').isObject(),
|
||||
validate,
|
||||
shardVisibility.putVisibility,
|
||||
)
|
||||
|
||||
module.exports = shardRouter
|
||||
117
server/src/router/v1/admin/shardAtlas.controller.js
Normal file
117
server/src/router/v1/admin/shardAtlas.controller.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// ── Admin · Spawn atlas ────────────────────────────────────────────────────
|
||||
//
|
||||
// Operating the atlas import: where the ServUO tree is, whether it has drifted
|
||||
// from what is loaded, and the approve/reject decision for a refresh that would
|
||||
// remove a facet (docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// The policy lives in the model. This controller does three things and no more:
|
||||
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||
// records the action in the admin activity log.
|
||||
//
|
||||
// **A refresh result is not an exception.** `shardAtlas.refresh()` reports
|
||||
// `unavailable` / `failed` / `needsReview` rather than throwing, because the boot
|
||||
// path must never be stopped by a bad tree. That contract is preserved here: an
|
||||
// unreadable mount is a 200 carrying `status: 'unavailable'`, not a 500. The
|
||||
// admin needs to be told what is wrong with their path, and a 500 says only
|
||||
// "something broke".
|
||||
|
||||
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-shard-atlas')
|
||||
|
||||
// GET /admin/shard/atlas — what is loaded, what the tree looks like, what is
|
||||
// staged. Unlike the public /atlas/meta route this DOES carry the filesystem
|
||||
// path and the drift flag: that is the whole point of the panel.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
return res.json(await atlas.status())
|
||||
} catch (err) {
|
||||
log.error('getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/atlas/import — apply a map change without a restart.
|
||||
//
|
||||
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||
// hatch for "the database is wrong but the tree is not"). Facet loss is still
|
||||
// staged rather than applied — approving is a separate, explicit act.
|
||||
async function importAtlas(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const result = await atlas.refresh({ force })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.atlas.import',
|
||||
detail: { force, status: result.status, counts: result.counts ?? null },
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('importAtlas', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/atlas/approve — apply a staged refresh, facet loss and all.
|
||||
//
|
||||
// Re-parses the tree rather than applying something captured at boot: only the
|
||||
// DECISION was stored, so what lands matches the tree as it is now. If the
|
||||
// operator has since fixed a half-copied mount, the approved import is simply
|
||||
// the corrected one — which is the desired outcome, not a surprise.
|
||||
async function approve(req, res) {
|
||||
try {
|
||||
const result = await atlas.approvePending()
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.atlas.approve',
|
||||
detail: { status: result.status, removed: result.removedFacets ?? null },
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('approveAtlas', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/atlas/reject — keep the current atlas and remember the
|
||||
// decision against those exact source hashes, so a declined refresh does not
|
||||
// re-prompt on every restart. Changing the tree asks again.
|
||||
async function reject(req, res) {
|
||||
try {
|
||||
const result = await atlas.rejectPending()
|
||||
if (result.status === 'none') {
|
||||
return res.status(404).json({ message: 'No refresh is awaiting review.' })
|
||||
}
|
||||
await activity.log({ req, action: 'shard.atlas.reject', detail: {} })
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('rejectAtlas', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/shard/atlas/path — point the atlas at a different ServUO tree.
|
||||
//
|
||||
// Persisted as a setting, which wins over the SERVUO_PATH env default so an
|
||||
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||
// the atlas off (boot skips, the loaded atlas keeps serving) — that is a
|
||||
// legitimate thing to want, so it is allowed rather than validated away.
|
||||
//
|
||||
// Deliberately does NOT import as a side effect: changing where the atlas reads
|
||||
// from and reloading it are separate decisions, and an operator fixing a typo
|
||||
// should not have a multi-thousand-row replace happen under them. The response
|
||||
// carries the refreshed status so the panel can offer the import immediately.
|
||||
async function setPath(req, res) {
|
||||
try {
|
||||
const value = String(req.body?.path ?? '').trim()
|
||||
await atlas.setServuoPath(value, req.user?.id ?? null)
|
||||
await activity.log({ req, action: 'shard.atlas.path', detail: { path: value } })
|
||||
return res.json(await atlas.status())
|
||||
} catch (err) {
|
||||
log.error('setAtlasPath', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, importAtlas, approve, reject, setPath }
|
||||
106
server/src/router/v1/admin/shardClilocs.controller.js
Normal file
106
server/src/router/v1/admin/shardClilocs.controller.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// ── Admin · Cliloc table ───────────────────────────────────────────────────
|
||||
//
|
||||
// Operating the cliloc import: where the converted cliloc file is, whether it
|
||||
// has drifted from what is loaded, and a forced reimport after a client patch
|
||||
// (docs/website/CLILOCS.md).
|
||||
//
|
||||
// The policy lives in the model. This controller does three things and no more:
|
||||
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||
// records the action in the admin activity log.
|
||||
//
|
||||
// **A refresh result is not an exception.** `shardClilocs.refresh()` reports
|
||||
// `unavailable` / `failed` rather than throwing, because the boot path must never
|
||||
// be stopped by a bad file. That contract is preserved here: a missing file, or
|
||||
// the single most likely operator mistake — pointing at the client's own
|
||||
// COMPRESSED `Cliloc.enu` — is a 200 carrying `status: 'unavailable'` and the
|
||||
// reason, not a 500. A 500 would say only "something broke"; the operator needs
|
||||
// to be told which file to convert.
|
||||
|
||||
const clilocs = require('../../../model/shardClilocs/shardClilocs.model')
|
||||
const market = require('../../../model/shardMarket/shardMarket.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-shard-clilocs')
|
||||
|
||||
// GET /admin/shard/clilocs — what is loaded, what the file looks like, whether
|
||||
// they disagree. There is no public counterpart: the cliloc table is never
|
||||
// served as a table, only applied to names the site already returns.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
return res.json(await clilocs.status())
|
||||
} catch (err) {
|
||||
log.error('getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/clilocs/import — reload after a client patch or a change to
|
||||
// the shard's own overlay files, without a restart.
|
||||
//
|
||||
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||
// hatch for "the database is wrong but the files are not").
|
||||
//
|
||||
// `approve` accepts a refresh in which a previously-loaded source has VANISHED.
|
||||
// That is refused by default because an unmounted volume and a deliberate
|
||||
// deletion look identical from the server — the lighter cousin of the atlas's
|
||||
// approve/reject flow, and the reason it can be a flag here rather than a
|
||||
// pending table is that nothing is stored to approve: the import re-reads the
|
||||
// files at approval time by construction.
|
||||
async function importClilocs(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const approve = !!req.body?.approve
|
||||
const result = await clilocs.refresh({ force, approve })
|
||||
|
||||
// The marketplace denormalizes resolved item names into
|
||||
// shard_vendor_items.display_name, and the shard's market sweep will NOT
|
||||
// re-send an unchanged shop just because the site learned what its items are
|
||||
// called — so without this pass, an operator who imports clilocs after the
|
||||
// first sweep keeps seeing item ids until every shop happens to change.
|
||||
// Awaited (rather than fired and forgotten) so the panel's "imported" is
|
||||
// honest about the names being live; the pass is a bounded walk of one table
|
||||
// and never throws.
|
||||
if (result.status === 'imported') await market.refreshDisplayNames()
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.clilocs.import',
|
||||
detail: {
|
||||
force,
|
||||
approve,
|
||||
status: result.status,
|
||||
count: result.count ?? null,
|
||||
missingSources: result.missingSources ?? result.acceptedMissing ?? null,
|
||||
},
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('importClilocs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/shard/clilocs/path — point the site at a different cliloc file.
|
||||
//
|
||||
// Persisted as a setting, which wins over the UO_CLIENT_PATH env default so an
|
||||
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||
// resolution off (boot skips, the loaded table keeps serving) — a legitimate
|
||||
// thing to want, so it is allowed rather than validated away.
|
||||
//
|
||||
// Deliberately does NOT import as a side effect, for the same reason the atlas
|
||||
// path does not: changing where the table reads from and reloading it are
|
||||
// separate decisions. The response carries the refreshed status so the panel can
|
||||
// offer the import immediately.
|
||||
async function setPath(req, res) {
|
||||
try {
|
||||
const value = String(req.body?.path ?? '').trim()
|
||||
await clilocs.setClientPath(value, req.user?.id ?? null)
|
||||
await activity.log({ req, action: 'shard.clilocs.path', detail: { path: value } })
|
||||
return res.json(await clilocs.status())
|
||||
} catch (err) {
|
||||
log.error('setClilocPath', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, importClilocs, setPath }
|
||||
98
server/src/router/v1/admin/shardVisibility.controller.js
Normal file
98
server/src/router/v1/admin/shardVisibility.controller.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// ── Admin · Shard visibility ───────────────────────────────────────────────
|
||||
//
|
||||
// Read/write the per-feature audience config that gates every shard-derived
|
||||
// surface. Admin-only: this decides what anonymous visitors can see, so it is
|
||||
// not part of the moderator tier.
|
||||
//
|
||||
// The policy itself (the ladder, the feature catalog, which fields are locked)
|
||||
// lives in utils/shardVisibility.js. This controller only validates input
|
||||
// against that policy and persists it.
|
||||
|
||||
const model = require('../../../model/shardVisibility/shardVisibility.model')
|
||||
const visibility = require('../../../utils/shardVisibility')
|
||||
const log = require('../../../utils/logger')('admin-shard-visibility')
|
||||
|
||||
// GET /admin/shard/visibility — the effective config (defaults merged with any
|
||||
// stored overrides), plus the vocabulary the admin UI needs to render itself:
|
||||
// the ladder, and which fields each feature exposes as configurable.
|
||||
async function getVisibility(req, res) {
|
||||
try {
|
||||
const config = await visibility.getConfig()
|
||||
return res.json({
|
||||
ladder: visibility.LADDER,
|
||||
lockedFields: Object.keys(visibility.LOCKED_FIELDS),
|
||||
defaults: visibility.compileDefaults(),
|
||||
features: config,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('getVisibility', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/shard/visibility — replace the settings for one or more features.
|
||||
// Body: { features: { <name>: { enabled, audience, stream, fieldRules } } }
|
||||
//
|
||||
// Rejects unknown feature names, unknown rungs, and any attempt to configure a
|
||||
// locked field — a 400 rather than a silent drop, so an admin who tries to make
|
||||
// `acct` public learns that it is not negotiable.
|
||||
async function putVisibility(req, res) {
|
||||
try {
|
||||
const incoming = req.body?.features
|
||||
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
|
||||
return res.status(400).json({ message: 'features object required' })
|
||||
}
|
||||
|
||||
const entries = []
|
||||
for (const [name, patch] of Object.entries(incoming)) {
|
||||
if (!visibility.isFeature(name)) {
|
||||
return res.status(400).json({ message: `Unknown feature: ${name}` })
|
||||
}
|
||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
return res.status(400).json({ message: `Invalid settings for ${name}` })
|
||||
}
|
||||
if (patch.audience != null && !visibility.isLevel(patch.audience)) {
|
||||
return res.status(400).json({ message: `Unknown audience for ${name}: ${patch.audience}` })
|
||||
}
|
||||
|
||||
const fieldRules = {}
|
||||
for (const [field, level] of Object.entries(patch.fieldRules || {})) {
|
||||
// Matches flattened spellings too (`ownerAcct`, `leaderWebId`), so the
|
||||
// rejection covers every way the field can be named rather than the two
|
||||
// canonical keys.
|
||||
if (visibility.isLockedField(field)) {
|
||||
return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` })
|
||||
}
|
||||
if (!visibility.isLevel(level)) {
|
||||
return res.status(400).json({ message: `Unknown rung for ${name}.${field}: ${level}` })
|
||||
}
|
||||
fieldRules[field] = level
|
||||
}
|
||||
|
||||
const current = (await visibility.getConfig())[name]
|
||||
entries.push({
|
||||
feature: name,
|
||||
enabled: patch.enabled == null ? current.enabled : !!patch.enabled,
|
||||
audience: patch.audience ?? current.audience,
|
||||
stream: patch.stream == null ? current.stream : !!patch.stream,
|
||||
fieldRules,
|
||||
updatedBy: req.user?.id ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of entries) await model.upsert(entry)
|
||||
visibility.invalidate()
|
||||
|
||||
log.info('shard visibility updated', {
|
||||
by: req.user?.id,
|
||||
features: entries.map((e) => e.feature),
|
||||
})
|
||||
|
||||
return res.json({ features: await visibility.getConfig() })
|
||||
} catch (err) {
|
||||
log.error('putVisibility', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getVisibility, putVisibility }
|
||||
99
server/src/router/v1/admin/uoLink.router.js
Normal file
99
server/src/router/v1/admin/uoLink.router.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Admin · uo-link — the sidecar connection config, the town crier, and the
|
||||
// staff SSE stream.
|
||||
//
|
||||
// Mounted at /api/v1/admin/uo-link by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. This is where shard integration is
|
||||
// configured: base/ws URL, bearer token, protocol version and the enabled
|
||||
// toggle all live in the DB (uoLinkConfig), never in env. The token is
|
||||
// write-only over this API (SECURITY note in uoLink.controller.js).
|
||||
//
|
||||
// /stream is the ADMIN SSE channel — it carries staff audit, cheat detection
|
||||
// and login attempts on top of the public event kinds. The public/admin
|
||||
// allowlist split in utils/shardIngest.js is a security boundary; the adminOnly
|
||||
// gate below is its other half.
|
||||
//
|
||||
// The routes keep their `Admin · Shard` swagger tag: retagging is a real
|
||||
// OpenAPI diff and does not belong in a route-move PR.
|
||||
//
|
||||
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
||||
// the middleware chain each route carries is unchanged by the move.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const uoLinkRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
uoLinkRouter.get(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
uoLink.getConfig,
|
||||
)
|
||||
uoLinkRouter.put(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Save uo-link connection config (admin only)'
|
||||
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||||
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
|
||||
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||||
body('protocol').optional().isInt({ min: 1, max: 99 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
validate,
|
||||
uoLink.saveConfig,
|
||||
)
|
||||
uoLinkRouter.post(
|
||||
'/towncrier',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||
body('lines').isArray({ min: 1, max: 8 }),
|
||||
body('lines.*').isString().isLength({ max: 200 }),
|
||||
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
|
||||
validate,
|
||||
uoLink.postTownCrier,
|
||||
)
|
||||
uoLinkRouter.delete(
|
||||
'/towncrier/:id',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Remove a town-crier message (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
uoLink.deleteTownCrier,
|
||||
)
|
||||
uoLinkRouter.get(
|
||||
'/stream',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
|
||||
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||
adminOnly,
|
||||
uoLink.stream,
|
||||
)
|
||||
|
||||
module.exports = uoLinkRouter
|
||||
33
server/src/router/v1/admin/uploads.router.js
Normal file
33
server/src/router/v1/admin/uploads.router.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Admin · Uploads — the generalized image upload used by the rich-text editors
|
||||
// (wiki, CMS pages). Returns { url }, where the posts-specific sibling
|
||||
// POST /admin/posts/upload returns { image_url }; both write to the same
|
||||
// directory through the shared multer config in imageUpload.js.
|
||||
//
|
||||
// Mounted at /api/v1/admin/uploads by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate — same editor tier as posts.
|
||||
//
|
||||
// The swagger tag stays 'Admin · Posts', matching the committed spec. Retagging
|
||||
// it would be a real OpenAPI diff, not a route move, so it does not belong in a
|
||||
// split PR whose acceptance criterion is a byte-identical spec.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
|
||||
const uploadsRouter = express.Router()
|
||||
|
||||
uploadsRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Upload an image for rich-text editors (multipart)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Stored file URL', content: { "application/json": { schema: { $ref: "#/components/schemas/UploadResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No file / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
upload.single('image'),
|
||||
ctrl.uploadFile,
|
||||
)
|
||||
|
||||
module.exports = uploadsRouter
|
||||
249
server/src/router/v1/admin/users.router.js
Normal file
249
server/src/router/v1/admin/users.router.js
Normal file
@@ -0,0 +1,249 @@
|
||||
// Admin · Users — user management, MFA recovery, and a user's shard footprint.
|
||||
//
|
||||
// Mounted at /api/v1/admin/users by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
|
||||
// and moderators manage content and reports, never accounts.
|
||||
//
|
||||
// Handlers still live in admin.controller.js (users) and usersShard.controller.js
|
||||
// (uo-link footprint); this PR re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
// Same shape the shard routes validate account names with.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
|
||||
const usersRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
usersRouter.use(adminOnly)
|
||||
usersRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'List users (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listUsers,
|
||||
)
|
||||
usersRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Create a user (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||||
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||||
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.createUser,
|
||||
)
|
||||
usersRouter.put(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Update a user (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
||||
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||||
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||||
body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.updateUser,
|
||||
)
|
||||
usersRouter.delete(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Delete a user (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.deleteUser,
|
||||
)
|
||||
|
||||
// ── A user's trusted devices & MFA (admin only) ───────────────────────
|
||||
usersRouter.get(
|
||||
'/:id/trusted-devices',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'List a user’s trusted devices (admin only)'
|
||||
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.listUserTrustedDevices,
|
||||
)
|
||||
usersRouter.delete(
|
||||
'/:id/trusted-devices',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.revokeAllUserTrustedDevices,
|
||||
)
|
||||
usersRouter.delete(
|
||||
'/:id/trusted-devices/:deviceId',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
param('deviceId').isInt({ min: 1 }),
|
||||
validate,
|
||||
ctrl.revokeUserTrustedDevice,
|
||||
)
|
||||
usersRouter.post(
|
||||
'/:id/mfa/reset',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Reset a user’s MFA (admin only)'
|
||||
// #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.resetUserMfa,
|
||||
)
|
||||
|
||||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||
// already grants admins a bypass to any account), so no routes for them here.
|
||||
usersRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Get a single user (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getUser,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
usersRouter.delete(
|
||||
'/:id/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
)
|
||||
|
||||
module.exports = usersRouter
|
||||
220
server/src/router/v1/admin/wiki.router.js
Normal file
220
server/src/router/v1/admin/wiki.router.js
Normal file
@@ -0,0 +1,220 @@
|
||||
// Admin · Wiki — wiki pages with revision history, plus the category and tag
|
||||
// vocabularies they draw on.
|
||||
//
|
||||
// Mounted at /api/v1/admin/wiki by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate — editors own the wiki.
|
||||
//
|
||||
// Handlers still live in admin.controller.js; this PR re-wires routes, not logic.
|
||||
//
|
||||
// ORDER IS LOAD-BEARING: the static /categories and /tags paths must stay ahead
|
||||
// of /:slug, or `GET /admin/wiki/categories` would be dispatched as a page whose
|
||||
// slug is "categories". The route manifest sorts its entries, so it cannot catch
|
||||
// a reordering here — keep the declaration order below as it is.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const wikiRouter = express.Router()
|
||||
|
||||
// ── Wiki categories (static paths registered before /:slug) ────────────
|
||||
wikiRouter.get(
|
||||
'/categories',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'List wiki categories'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Wiki categories', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiCategory" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listWikiCategories,
|
||||
)
|
||||
wikiRouter.post(
|
||||
'/categories',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Create a wiki category'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('slug').matches(/^[a-z0-9-]+$/),
|
||||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||||
body('sort_order').optional().isInt(),
|
||||
validate,
|
||||
ctrl.createWikiCategory,
|
||||
)
|
||||
wikiRouter.put(
|
||||
'/categories/:id',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Update a wiki category'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('slug').optional().matches(/^[a-z0-9-]+$/),
|
||||
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||||
body('sort_order').optional().isInt(),
|
||||
validate,
|
||||
ctrl.updateWikiCategory,
|
||||
)
|
||||
wikiRouter.delete(
|
||||
'/categories/:id',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Delete a wiki category'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.deleteWikiCategory,
|
||||
)
|
||||
|
||||
// ── Wiki tags ──────────────────────────────────────────────────────────
|
||||
wikiRouter.get(
|
||||
'/tags',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'List wiki tags'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Wiki tags', content: { "application/json": { schema: { type: "array", items: { type: "string" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listWikiTags,
|
||||
)
|
||||
|
||||
// ── Wiki pages ─────────────────────────────────────────────────────────
|
||||
wikiRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'List all wiki pages (including unpublished)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Wiki pages', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiPage" } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listWiki,
|
||||
)
|
||||
wikiRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Create a wiki page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPageCreateRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('slug').matches(/^[a-z0-9-]+$/),
|
||||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||||
body('category_id').optional({ values: 'null' }).isInt(),
|
||||
body('published').optional().isBoolean(),
|
||||
body('tags').optional().isArray(),
|
||||
validate,
|
||||
ctrl.createWiki,
|
||||
)
|
||||
wikiRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Get a wiki page by slug'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getWiki,
|
||||
)
|
||||
wikiRouter.put(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Update a wiki page (creates a revision)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { allOf: [ { $ref: "#/components/schemas/WikiPageCreateRequest" }, { type: "object", properties: { change_note: { type: "string", maxLength: 280 } } } ] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||||
body('category_id').optional({ values: 'null' }).isInt(),
|
||||
body('published').optional().isBoolean(),
|
||||
body('tags').optional().isArray(),
|
||||
body('change_note').optional({ values: 'falsy' }).isString().isLength({ max: 280 }),
|
||||
validate,
|
||||
ctrl.updateWiki,
|
||||
)
|
||||
wikiRouter.patch(
|
||||
'/:slug/publish',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Publish / unpublish a wiki page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('published').isBoolean(),
|
||||
validate,
|
||||
ctrl.publishWiki,
|
||||
)
|
||||
wikiRouter.get(
|
||||
'/:slug/revisions',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'List revisions of a wiki page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
/* #swagger.responses[200] = { description: 'Revisions', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listWikiRevisions,
|
||||
)
|
||||
wikiRouter.get(
|
||||
'/:slug/revisions/:id',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Get a single wiki revision'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id.' }
|
||||
/* #swagger.responses[200] = { description: 'The revision', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.getWikiRevision,
|
||||
)
|
||||
wikiRouter.post(
|
||||
'/:slug/revisions/:id/restore',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Restore a wiki page to a revision'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id to restore.' }
|
||||
/* #swagger.responses[200] = { description: 'Restored wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.restoreWikiRevision,
|
||||
)
|
||||
wikiRouter.delete(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Admin · Wiki']
|
||||
// #swagger.summary = 'Delete a wiki page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the slug)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedSlug" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.deleteWiki,
|
||||
)
|
||||
|
||||
module.exports = wikiRouter
|
||||
@@ -1,8 +1,10 @@
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const { setAuthCookie, clearAuthCookie, setTrustCookie } = require('../../../auth/token')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const { establishTrust } = require('./trustDevice.helper')
|
||||
const totp = require('../../../utils/totp')
|
||||
const botScore = require('../../../middleware/botScore')
|
||||
const loginProtection = require('../../../middleware/loginProtection')
|
||||
@@ -27,14 +29,14 @@ function needsTotp(user) {
|
||||
// the cookie, clear the IP's failure backoff, and record the login. authMethod
|
||||
// records how this session was authenticated ('local' password, or 'totp' after
|
||||
// the second factor) — carried in the session token for downstream visibility.
|
||||
async function issueSession(req, res, user, authMethod = 'local') {
|
||||
async function issueSession(req, res, user, authMethod = 'local', extra = undefined) {
|
||||
loginProtection.recordSuccess(req.ip)
|
||||
await users.recordLogin(user.id, req.ip)
|
||||
const { token } = sessionService.createSession(user, authMethod)
|
||||
setAuthCookie(req, res, token)
|
||||
await activity.log({ req, userId: user.id, action: 'auth.login' })
|
||||
log.info('login success', { username: user.username, id: user.id, ip: req.ip, authMethod })
|
||||
return res.json({ user: { id: user.id, username: user.username, role: user.role } })
|
||||
return res.json({ user: { id: user.id, username: user.username, role: user.role }, ...(extra || {}) })
|
||||
}
|
||||
|
||||
async function login(req, res) {
|
||||
@@ -68,9 +70,23 @@ async function login(req, res) {
|
||||
}
|
||||
|
||||
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
|
||||
// hand back a short-lived, signed "password verified" challenge and require
|
||||
// the code. If TOTP is off, log them straight in.
|
||||
// unless this browser is a trusted device, in which case the second factor is
|
||||
// skipped (the password was still required above). Otherwise hand back a
|
||||
// short-lived, signed "password verified" challenge and require the code.
|
||||
if (needsTotp(user)) {
|
||||
// Trusted-device skip: honor a valid trust token bound to THIS user. Any DB
|
||||
// hiccup falls through to the normal TOTP challenge (fail closed to TOTP).
|
||||
try {
|
||||
const device = await sessionService.resolveTrustedDevice(req)
|
||||
if (device && device.user_id === user.id) {
|
||||
await sessionService.honorTrustedDevice(device.id)
|
||||
await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device' })
|
||||
log.info('login via trusted device (TOTP skipped)', { username: user.username, id: user.id, ip: req.ip })
|
||||
return issueSession(req, res, user, 'totp')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('trusted-device check failed; falling back to TOTP', err)
|
||||
}
|
||||
const challenge = sessionService.createPartialSession(user)
|
||||
log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip })
|
||||
return res.json({ totpRequired: true, challenge })
|
||||
@@ -134,23 +150,56 @@ async function register(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// Second step for TOTP users: verify the challenge token + code, then issue the
|
||||
// session. A wrong code counts as a failed attempt (backoff + bot score).
|
||||
// Second step for TOTP users: verify the challenge token + a second factor, then
|
||||
// issue the session. The second factor is either the current authenticator `code`
|
||||
// OR a single-use `recoveryCode` (for users who lost their authenticator). A wrong
|
||||
// factor counts as a failed attempt (backoff + bot score). If `trustDevice` is set,
|
||||
// this browser is remembered so future logins skip the TOTP step — unless the user
|
||||
// is at the trusted-device cap, in which case the session is still issued and the
|
||||
// response carries a { trustLimitReached, devices } prompt to revoke one first.
|
||||
async function loginTotp(req, res) {
|
||||
const { challenge, code } = req.body
|
||||
const { challenge, code, recoveryCode, trustDevice, deviceName } = req.body
|
||||
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
|
||||
}
|
||||
try {
|
||||
const user = await users.getRawById(decoded.id)
|
||||
if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, code)) {
|
||||
if (!user || !user.totp_enabled) {
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
loginProtection.recordFailure(req.ip)
|
||||
log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip })
|
||||
return res.status(401).json({ message: 'Invalid verification code.' })
|
||||
}
|
||||
return issueSession(req, res, user, 'totp')
|
||||
|
||||
// Accept a TOTP code, or fall back to consuming a single-use recovery code.
|
||||
let verified = Boolean(code) && totp.verifyCode(user.totp_secret, code)
|
||||
let viaRecovery = false
|
||||
if (!verified && recoveryCode) {
|
||||
verified = await recoveryCodes.consumeForUser(user.id, recoveryCode)
|
||||
viaRecovery = verified
|
||||
}
|
||||
if (!verified) {
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
loginProtection.recordFailure(req.ip)
|
||||
log.warn('TOTP verify failed', { id: user.id, ip: req.ip, recovery: Boolean(recoveryCode) })
|
||||
return res.status(401).json({ message: 'Invalid verification code.' })
|
||||
}
|
||||
if (viaRecovery) {
|
||||
await activity.log({ req, userId: user.id, action: 'account.recovery_code.consume' })
|
||||
log.info('login via recovery code', { id: user.id, ip: req.ip })
|
||||
}
|
||||
|
||||
// Optionally remember this browser as a trusted device.
|
||||
let trustLimit = null
|
||||
if (trustDevice) {
|
||||
const result = await establishTrust(req, user, { platform: 'web', deviceName: deviceName || null })
|
||||
if (result.ok) setTrustCookie(req, res, result.trustToken)
|
||||
else if (result.capReached) trustLimit = result.devices
|
||||
}
|
||||
|
||||
const extra = trustLimit ? { trustLimitReached: true, devices: trustLimit } : undefined
|
||||
return issueSession(req, res, user, 'totp', extra)
|
||||
} catch (err) {
|
||||
log.error('loginTotp error', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
const { getInvite, acceptInvite } = require('./invite.controller')
|
||||
const { requestReset, lookupReset, confirmReset } = require('./passwordReset.controller')
|
||||
const { isLoggedIn } = require('../../../utils/auth')
|
||||
const { attachSession } = require('../../../auth/session.middleware')
|
||||
const {
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
} = require('../../../middleware/rateLimit')
|
||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const mobileRouter = require('./mobile.routes')
|
||||
const ssoRouter = require('./sso.routes')
|
||||
const meRouter = require('./me.routes')
|
||||
const notifRouter = require('./notifications.routes')
|
||||
|
||||
const authRouter = express.Router()
|
||||
|
||||
// Native/Android bearer-token auth. Additive alongside the web cookie flow below.
|
||||
authRouter.use('/mobile', mobileRouter)
|
||||
|
||||
// SSO discovery + OAuth redirect flow (/auth/providers, /auth/sso/:provider/*).
|
||||
// Additive; the web cookie + TOTP flow below is unchanged.
|
||||
authRouter.use(ssoRouter)
|
||||
|
||||
// Role-agnostic self-service ("me") — /auth/me/account*, reusing the same
|
||||
// account.controller handlers as /player/account/* and /admin/account/* behind
|
||||
// requireAuth (any role). Additive; gives the app one self surface that never
|
||||
// touches /admin. The bare GET /me below is unaffected (meRouter has no /account-
|
||||
// free route, so /me falls through to its own handler).
|
||||
authRouter.use('/me', meRouter)
|
||||
|
||||
// Push-notification self-service — /auth/me/devices*, /auth/me/notifications/*.
|
||||
// A second sub-router at /me (Express allows multiple), same requireAuth gate,
|
||||
// keeping the notification surface separate from the account/identity handlers.
|
||||
authRouter.use('/me', notifRouter)
|
||||
|
||||
// Login protection order (cheapest rejection first):
|
||||
// backoffGuard → per-IP exponential lockout on repeated failures
|
||||
// slowLogin → progressive per-request delay within the window
|
||||
// loginLimiter → hard 10-per-15-min cap
|
||||
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
|
||||
|
||||
authRouter.post(
|
||||
'/login',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Log in with username and password'
|
||||
// #swagger.description = 'On success sets the httpOnly session cookie. If the account has 2FA enabled, returns { totpRequired, challenge } instead and no cookie is set — complete login at POST /login/totp. Rate limited and behind bot/backoff guards.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/LoginRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Session issued, or TOTP challenge required', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Incorrect username or password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
body('username').isString().trim().notEmpty(),
|
||||
body('password').isString().notEmpty(),
|
||||
// Honeypot must be absent/empty for humans; bots that fill it are caught in
|
||||
// the controller. Accept-but-ignore here so a filled value still reaches it.
|
||||
body(HONEYPOT_FIELD).optional(),
|
||||
validate,
|
||||
login,
|
||||
)
|
||||
|
||||
// Public self-registration (player accounts). Gated in the controller by the
|
||||
// player_registration setting; here it reuses the login backoff/limiter stack
|
||||
// plus its own per-IP cap, and accepts the honeypot field.
|
||||
authRouter.post(
|
||||
'/register',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Register a player account'
|
||||
// #swagger.description = 'Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Registration is not open', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
registerLimiter,
|
||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||||
body(HONEYPOT_FIELD).optional(),
|
||||
validate,
|
||||
register,
|
||||
)
|
||||
|
||||
// Second factor: same throttling, since it's a code-guessing surface too.
|
||||
authRouter.post(
|
||||
'/login/totp',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Complete login with a TOTP code'
|
||||
// #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus the current authenticator code for a session cookie.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpLoginRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
body('challenge').isString().notEmpty(),
|
||||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||
validate,
|
||||
loginTotp,
|
||||
)
|
||||
|
||||
// ── Email-invite acceptance (public, token-gated) ──────────────────────────
|
||||
authRouter.get(
|
||||
'/invite/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Look up an email invite by token'
|
||||
// #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.'
|
||||
/* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
validate,
|
||||
getInvite,
|
||||
)
|
||||
authRouter.post(
|
||||
'/invite/:token/accept',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
|
||||
// #swagger.description = 'Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
registerLimiter,
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
body(HONEYPOT_FIELD).optional(),
|
||||
validate,
|
||||
acceptInvite,
|
||||
)
|
||||
|
||||
// ── Self-service password reset (public, token-gated) ──────────────────────
|
||||
// Request → email a tokened link; then validate the link and set a new password.
|
||||
// The request step never reveals whether an email exists (always 200, generic).
|
||||
authRouter.post(
|
||||
'/password/forgot',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Request a password-reset link by email'
|
||||
// #swagger.description = 'Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email"], properties: { email: { type: "string", format: "email" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Generic acknowledgement (sent if the account exists)', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many requests', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
passwordResetRequestLimiter,
|
||||
body('email').isString().trim().isEmail().isLength({ max: 255 }),
|
||||
validate,
|
||||
requestReset,
|
||||
)
|
||||
authRouter.get(
|
||||
'/password/reset/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Validate a password-reset link'
|
||||
// #swagger.description = 'Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).'
|
||||
/* #swagger.responses[200] = { description: 'Reset link is valid', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
validate,
|
||||
lookupReset,
|
||||
)
|
||||
authRouter.post(
|
||||
'/password/reset/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Set a new password from a reset link'
|
||||
// #swagger.description = 'Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["password"], properties: { password: { type: "string", minLength: 8, maxLength: 64 } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid, expired, or already-used reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
passwordResetConfirmLimiter,
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
confirmReset,
|
||||
)
|
||||
|
||||
authRouter.post(
|
||||
'/logout',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Log out (clear the cookie and revoke this session)'
|
||||
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
// Best-effort attach (never rejects) so the controller can revoke this session's
|
||||
// jti — logout stays a no-op for an already-anonymous caller.
|
||||
attachSession,
|
||||
logout,
|
||||
)
|
||||
authRouter.get(
|
||||
'/me',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Current authenticated user'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The signed-in user', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
isLoggedIn,
|
||||
me,
|
||||
)
|
||||
|
||||
module.exports = authRouter
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user