Compare commits
236 Commits
d89cc7e691
...
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 | |||
| 2306545574 | |||
| 70849f96ee | |||
| 1edef8e6db | |||
| dc90df9fff | |||
| 68126efc0b | |||
| 401db8f75c | |||
| 9b0f2d93d8 | |||
| 12d50fd615 | |||
| 4993470fa2 | |||
| 2f3e8f7df5 | |||
| 68a4e9295b | |||
| 886a504152 | |||
| 35e5269ec5 | |||
| 99fd9acddb | |||
| b474303052 | |||
| e515fc0c9d | |||
| c73273f1bc | |||
| 5e5e0d7a91 | |||
| e1461d9161 | |||
| 82bf2c972c | |||
| c54bb54834 | |||
| 86420661b5 | |||
| d1b3351360 | |||
| dacc1bd4f7 | |||
| bcc96e7cfb | |||
| d37c3a46a9 | |||
| e3dd5358b6 | |||
| 61f4591a6b | |||
| 31b72859ce | |||
| a789ee3ac9 | |||
| 4fa73d3ccf | |||
| 416761f8f7 | |||
| 030414f13d | |||
| c35509e8b3 | |||
| 90c8eae20f | |||
| 93a2c0d55f | |||
| fc5255da99 | |||
| 715eaedd74 | |||
| 250cb1e2d3 | |||
| 10aed49bb6 | |||
| 896773b8a5 | |||
| 60e6a60842 | |||
| 9ac1f35fa0 | |||
| 028ba8c5e4 | |||
| 5f09ab1146 | |||
| b0549f5845 | |||
| aa2177715e | |||
| 3e6ecb959a | |||
|
|
d441e92029 | ||
| 5639117936 | |||
| 50133155d6 | |||
| bdce23f9b6 | |||
| a0a70ce2ca | |||
| 526160721a | |||
| 352ae4f256 | |||
| a16092f13a | |||
| 7a08546da6 | |||
| 1bb9e3c3c3 | |||
| b8f67fb208 | |||
| e0fadbdcc2 | |||
| b3033909d3 | |||
| 6c967d9a6c | |||
| ee085496ab | |||
| 3ef1c8e438 | |||
| 1629796235 | |||
| a165c90c62 | |||
| 2976d5982f | |||
| 91c206bf76 | |||
| 55a3adea99 | |||
| 2957708bab | |||
| e9aa19a83d | |||
| 080478c4a1 | |||
| 3b333b1b49 | |||
| 0facdb2b2a | |||
| 49ad6891cf | |||
| 97d95052db | |||
| e744723db2 | |||
| fc2554e5c3 | |||
| 70122f3626 | |||
| 64da0067f1 | |||
| 5b6b63e1bc | |||
| 01b3bb52bf | |||
| c31553aeb6 | |||
| 2dc360ca48 | |||
| 34c511c8d0 | |||
| 4fe90ea368 | |||
| ba4d758eab | |||
| 696d82f114 | |||
| f4e7fc7e20 | |||
| 6d4cd91bcc | |||
| 3628268dda | |||
| 25ff5aa836 | |||
| 042a151358 | |||
| 4f24959d49 | |||
| 99649727f3 | |||
| ac858875c0 | |||
| 986a8d5d86 | |||
| 350433635b | |||
| a2590812e0 | |||
| 5ccb18e794 | |||
| bf9edde5b7 | |||
| 6c310629c7 | |||
| d72c2dadfc | |||
| c4245e3f6a | |||
| 49d0c1bd11 | |||
| 74d2ead958 | |||
| 49ce230c3a | |||
| fe6f93481b | |||
| e7bc316863 | |||
| 1c9a9d26e1 | |||
| 064f02c4b6 | |||
| 523113f013 | |||
| 9d9f5aac28 | |||
| ab647756f0 | |||
| d49008e9f2 | |||
| e7f5f24809 | |||
| 1dd7603f54 | |||
| 4d87c5f627 | |||
| 764fb0c069 | |||
| 6d31869ba2 | |||
| fcef08e9b6 | |||
| 6180e8a071 | |||
| d7fc2dccb7 | |||
| 455e850b91 | |||
| f8652c2399 | |||
| 5b3ab7f282 | |||
| 17d42cebfe | |||
| 5da27879e5 | |||
| cda0c16149 | |||
| 82807d18d9 | |||
| d72deff2cc | |||
| 387db52510 | |||
| 5daf260db9 | |||
| f8bcc7f6a3 | |||
| cdd916e199 | |||
| 4ab46410be | |||
| 2b4c4c5235 | |||
| 3027bb0400 | |||
| b0c0d1fe9b | |||
| f2691959ff | |||
| 60d2121b83 | |||
| 20d3fbf594 | |||
| f8db61025b | |||
| 2067028070 | |||
| 03e62b56ad | |||
| 15cf8ea286 | |||
| 5f62eccdd8 | |||
| e8a54d9ff7 | |||
| 933206a1b8 | |||
| 1cfb79f5ae | |||
| 3ef84b41ef | |||
| 5df943095d | |||
| bb5cc68c54 | |||
| 17c1eb07e8 | |||
| 7a21cc636c | |||
| ad7aebb3ba | |||
| 0318d6fe9f | |||
| 433e02d3ef | |||
| a1f0675577 | |||
| d7fb274bad | |||
| 6af85c30b6 | |||
| 86e44a94a2 | |||
| 31b31c3a17 | |||
| 8fa34ca68e | |||
| 870971fc12 | |||
| 58852a5078 | |||
| cd678e75ce | |||
| a82f839c61 | |||
| 05933f8d94 | |||
| 073c010d72 | |||
| f305019c54 | |||
| 7e8ffeee6f | |||
| 6ab3e47d38 | |||
| ea46b5d346 | |||
| d38c98ad9e | |||
| ad9c556c9a | |||
| e84835a0fb |
@@ -6,6 +6,18 @@
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--prefix", "client"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--prefix", "server"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "bot",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--prefix", "bot"],
|
||||
"port": 4100
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
124
.env.example
124
.env.example
@@ -1,9 +1,22 @@
|
||||
# ─── UOMysticmoon — root environment (used by docker-compose) ───
|
||||
# ─── Runic Gateway — root environment (used by docker-compose) ───
|
||||
# Copy to .env and fill in. NEVER commit the real .env.
|
||||
# To run this as an existing branded instance (e.g. UOMysticmoon), see
|
||||
# .env.uomysticmoon.example for the exact BRAND_*/DB pinning to copy in.
|
||||
|
||||
# Container image tag pulled by docker-compose (app + bot). Published by the
|
||||
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
|
||||
# Leave as `latest` for routine deploys; pin to a specific build for a
|
||||
# reproducible deploy or rollback, e.g. IMAGE_TAG=sha-042a151.
|
||||
# Deploy: `docker compose pull && docker compose up -d`.
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# App
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
# Separate, UNPUBLISHED port for server<->bot internal traffic (the decrypted
|
||||
# bot-token route). Must match the port in the bot's SITE_INTERNAL_URL
|
||||
# (docker-compose.yml) and must NEVER be published/proxied. See issue #33.
|
||||
INTERNAL_PORT=3001
|
||||
UPLOAD_DIR=/app/uploads
|
||||
# Logging — written to BOTH the console and a log file.
|
||||
LOG_LEVEL=info # console verbosity: error | warn | info | debug
|
||||
@@ -12,11 +25,32 @@ LOG_TO_FILE=true # set false for console-only
|
||||
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
||||
LOG_FILE=app.log
|
||||
|
||||
# Database (the values here are shared by the `db` and `app` containers)
|
||||
# ─── Branding (BRAND_*) ───────────────────────────────────────────────────
|
||||
# Instance identity. Defaults render as "Runic Gateway"; set these to rebrand
|
||||
# without a rebuild. Text + colors reach the SPA through the settings API at
|
||||
# runtime; the server templates index.html <title>/meta/OG/favicon at boot. The
|
||||
# admin-editable "site title" and "contact email" settings, if set, override
|
||||
# BRAND_NAME / BRAND_CONTACT_EMAIL.
|
||||
BRAND_NAME=Runic Gateway
|
||||
BRAND_SHORT_NAME=Runic Gateway
|
||||
BRAND_TAGLINE=an independent private Ultima Online shard
|
||||
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
||||
BRAND_CONTACT_EMAIL=
|
||||
BRAND_URL=
|
||||
# Accent color — drives the web theme's --accent and the Discord embed color.
|
||||
BRAND_ACCENT_COLOR=#7f99bd
|
||||
# Image assets: paths under the /brand mount (see docker-compose.yml) or absolute
|
||||
# URLs. Blank = built-in defaults (hero falls back to a neutral built-in image).
|
||||
BRAND_LOGO=
|
||||
BRAND_HERO=
|
||||
BRAND_FAVICON=
|
||||
|
||||
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
|
||||
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
DB_NAME=runic_gateway
|
||||
DB_USER=runic
|
||||
DB_PASSWORD=change-me-db-password
|
||||
DB_ROOT_PASSWORD=change-me-root-password
|
||||
|
||||
@@ -26,20 +60,86 @@ JWT_EXPIRES_IN=1d
|
||||
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
|
||||
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
|
||||
COOKIE_SECURE=auto
|
||||
COOKIE_NAME=uomm_token
|
||||
# Changing this on a live instance invalidates existing sessions (users re-login).
|
||||
COOKIE_NAME=rg_token
|
||||
|
||||
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
||||
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
||||
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
|
||||
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
|
||||
# Integer hop count or "false" also accepted; a blanket "true" is rejected
|
||||
# (coerced to 1) to prevent X-Forwarded-For spoofing.
|
||||
TRUST_PROXY=1
|
||||
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
|
||||
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
||||
DEBUG_TRUST_PROXY=0
|
||||
|
||||
# Optional TOTP two-factor (opt-in per user). Defaults to BRAND_NAME when unset.
|
||||
# TOTP_ISSUER=Runic Gateway
|
||||
TOTP_CHALLENGE_TTL=5m
|
||||
|
||||
# First admin bootstrap — created only if no users exist yet.
|
||||
# Set, run once, then you can blank these out.
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the
|
||||
# client to fall back to a mailto: link instead.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
CONTACT_TO=UOMysticmoon@gmail.com
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
||||
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. Until it's connected, the contact form falls back to
|
||||
# a mailto: link (recipient = the `contact_email` site setting).
|
||||
|
||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
# Discord bot — internal API (server <-> bot/, see docker-compose.yml's `bot`
|
||||
# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same
|
||||
# variable in bot/.env.example — it is the only auth on both sides' /internal/*
|
||||
# routes, so a mismatch silently breaks every server<->bot call with 401s.
|
||||
# It also guards the server's /internal/bot-config route, which returns the
|
||||
# DECRYPTED Discord token; with NODE_ENV=production the app REFUSES TO START if
|
||||
# this is left blank, at this placeholder, or shorter than 16 chars. Generate a
|
||||
# long random string. The Discord bot TOKEN itself is not an env var — it's
|
||||
# entered in the admin panel (Discord Bot page) and stored encrypted in the DB.
|
||||
#
|
||||
# Defense in depth: even with a strong key, configure Pangolin/your reverse
|
||||
# proxy to DENY /api/v1/internal (and never forward INTERNAL_PORT). The route no
|
||||
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
||||
BOT_INTERNAL_URL=http://bot:4100
|
||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||
|
||||
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
||||
# website ingests its live event feed and proxies its read queries/commands
|
||||
# (shard status, online players, player-vendor sales, IDOC houses, character
|
||||
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
||||
# a DIFFERENT host from the website, so both URLs are configurable. The
|
||||
# shared-secret auth token is NOT an env var — it is entered in the admin panel
|
||||
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
|
||||
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
||||
# 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
|
||||
# for the Android app; docs/android/PLAN.md §11).
|
||||
# NTFY_BASE_URL Public URL devices reach the relay at (behind the
|
||||
# reverse proxy). Used BOTH to configure the ntfy service
|
||||
# AND as the backend's SSRF allow-set — a device may only
|
||||
# register an endpoint whose origin matches this.
|
||||
# NTFY_ALLOWED_ORIGINS Optional, comma-separated extra allowed endpoint origins
|
||||
# (defaults to NTFY_BASE_URL's origin). Set only if devices
|
||||
# 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
|
||||
|
||||
30
.env.uomysticmoon.example
Normal file
30
.env.uomysticmoon.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# ─── UOMysticmoon instance — BRAND_* / identity overrides ───
|
||||
#
|
||||
# Runic Gateway's first "tenant". Copy these into the deploy .env (on top of
|
||||
# .env.example) to run RunicGateway/website as UOMysticmoon. This is the proof
|
||||
# that branding is data, not code: the same image renders as UOMysticmoon purely
|
||||
# from these vars.
|
||||
#
|
||||
# Only the values that differ from the Runic Gateway defaults are shown.
|
||||
|
||||
# Identity
|
||||
BRAND_NAME=UOMysticmoon
|
||||
BRAND_SHORT_NAME=Mysticmoon
|
||||
BRAND_TAGLINE=an independent private Ultima Online shard
|
||||
BRAND_DESCRIPTION=UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
||||
BRAND_CONTACT_EMAIL=UOMysticmoon@gmail.com
|
||||
# BRAND_URL=https://<your public url>
|
||||
|
||||
# Visual — the existing UOM accent + hero image (baked into the image already).
|
||||
BRAND_ACCENT_COLOR=#7f99bd
|
||||
BRAND_HERO=/assets/img/uomysticmoon-main-hero.png
|
||||
|
||||
# TOTP label (defaults to BRAND_NAME, so optional — shown for clarity).
|
||||
TOTP_ISSUER=UOMysticmoon
|
||||
|
||||
# ── Infrastructure identifiers — PIN to the existing production values so the
|
||||
# ── app keeps talking to the same database and existing sessions stay valid.
|
||||
# ── (These are NOT branding; they must match what production already uses.)
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
COOKIE_NAME=uomm_token
|
||||
41
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
41
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report something that is broken or behaving unexpectedly
|
||||
title: "[bug] "
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- A clear, concise description of the bug. -->
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
<!-- What you expected to happen. -->
|
||||
|
||||
## Actual behavior
|
||||
|
||||
<!-- What actually happened. Include exact error messages and logs if you have them. -->
|
||||
|
||||
## Environment
|
||||
|
||||
- Component / repo:
|
||||
- Version or commit:
|
||||
- OS / runtime (Node, Rust, ServUO, browser…):
|
||||
- Deployment (Docker Compose, local dev, bare metal…):
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Screenshots, config (with secrets redacted), anything else that helps. -->
|
||||
|
||||
<!--
|
||||
Security issue? Do NOT file it here. See SECURITY.md and email
|
||||
whitlocktech@gmail.com instead.
|
||||
-->
|
||||
5
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/SECURITY.md
|
||||
about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md).
|
||||
23
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
23
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea, enhancement, or new capability
|
||||
title: "[feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
---
|
||||
|
||||
## Problem / motivation
|
||||
|
||||
<!-- What are you trying to do? What's missing or painful today? -->
|
||||
|
||||
## Proposed solution
|
||||
|
||||
<!-- What you'd like to see happen. -->
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
<!-- Other approaches you thought about, and why you prefer the one above. -->
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Mockups, links, related issues, affected component/repo, etc. -->
|
||||
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
Thanks for contributing to Runic Gateway!
|
||||
Please fill out the sections below and check every box before requesting review.
|
||||
-->
|
||||
|
||||
## What & why
|
||||
|
||||
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Commands you ran, manual steps, screenshots. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- [ ] The change builds and existing tests/checks pass locally.
|
||||
- [ ] I have added or updated tests/docs where it makes sense.
|
||||
- [ ] My commits are reasonably scoped with clear messages.
|
||||
|
||||
## AI-assisted contributions (required)
|
||||
|
||||
This project **requires disclosure of AI tool usage**. Please pick one:
|
||||
|
||||
- [ ] No AI tools were used to produce this contribution.
|
||||
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
|
||||
every change, and take responsibility for it. AI-authored commits are
|
||||
marked with a `Co-Authored-By` / `Assisted-By` trailer.
|
||||
|
||||
## License
|
||||
|
||||
- [ ] I agree that my contribution is licensed under this project's license
|
||||
(**GNU GPL v3.0 or later**), and I have the right to contribute it.
|
||||
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()
|
||||
120
.gitea/workflows/build-images.yml
Normal file
120
.gitea/workflows/build-images.yml
Normal file
@@ -0,0 +1,120 @@
|
||||
# Build the app + bot container images, publish them to Gitea's container
|
||||
# registry, then roll the production stack onto the fresh images — all on every
|
||||
# merge to main. Production only ever pulls prebuilt images; it never builds.
|
||||
#
|
||||
# Two jobs run in sequence:
|
||||
# build — builds & pushes website-app / website-bot images (on ubuntu-latest)
|
||||
# deploy — `needs: build`, so it starts only after a clean build+push, and
|
||||
# pulls + recreates the stack on the production host (on uom-deploy-runner)
|
||||
#
|
||||
# Prerequisites (one-time):
|
||||
# • An always-on Gitea runner with label `ubuntu-latest` whose jobs have the
|
||||
# host Docker socket mounted (/var/run/docker.sock), so `docker build` talks
|
||||
# to the host daemon. This also gives free layer caching between runs.
|
||||
# • A second self-hosted runner labelled `uom-deploy-runner` ON the production host,
|
||||
# with access to the Docker daemon and to /home/perry/website (the directory
|
||||
# holding the production docker-compose.yml + .env). This is what actually
|
||||
# rolls the stack; it must be able to `docker compose pull` from the registry
|
||||
# (log in once on the host, or ensure the images are public-read).
|
||||
# • Two repo secrets (Settings → Actions → Secrets):
|
||||
# REGISTRY_USER — the Gitea username that owns the token below
|
||||
# REGISTRY_TOKEN — a Gitea access token with `write:package` (+ read:package)
|
||||
# See the PR description / README for step-by-step token creation.
|
||||
#
|
||||
# Produces, in gitea.whitlocktech.com/<owner>/ :
|
||||
# website-app:latest + website-app:sha-<7>
|
||||
# website-bot:latest + website-bot:sha-<7>
|
||||
# then deploys the `:latest` images (docker-compose.yml defaults IMAGE_TAG=latest).
|
||||
|
||||
name: Build container images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: images-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.whitlocktech.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the merged commit
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image refs (registry owner must be lowercase for Docker)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
echo "APP_IMAGE=${REGISTRY}/${OWNER}/website-app" >> "$GITHUB_ENV"
|
||||
echo "BOT_IMAGE=${REGISTRY}/${OWNER}/website-bot" >> "$GITHUB_ENV"
|
||||
echo "TAG=sha-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify the Docker daemon is reachable
|
||||
# Fails fast with a clear message if the host socket isn't mounted into
|
||||
# the job container (the one hard runner prerequisite).
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "::error::Docker daemon not reachable. Mount /var/run/docker.sock into the runner's job containers."
|
||||
exit 1
|
||||
fi
|
||||
echo "Docker daemon OK"
|
||||
|
||||
- name: Log in to the Gitea container registry
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build & push the app image (server + client)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -f Dockerfile \
|
||||
-t "${APP_IMAGE}:latest" \
|
||||
-t "${APP_IMAGE}:${TAG}" \
|
||||
.
|
||||
docker push "${APP_IMAGE}:latest"
|
||||
docker push "${APP_IMAGE}:${TAG}"
|
||||
|
||||
- name: Build & push the bot image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -f bot/Dockerfile \
|
||||
-t "${BOT_IMAGE}:latest" \
|
||||
-t "${BOT_IMAGE}:${TAG}" \
|
||||
.
|
||||
docker push "${BOT_IMAGE}:latest"
|
||||
docker push "${BOT_IMAGE}:${TAG}"
|
||||
|
||||
- name: Log out (clear cached credentials from the runner)
|
||||
if: always()
|
||||
run: docker logout "${REGISTRY}" || true
|
||||
|
||||
deploy:
|
||||
# Roll production onto the images `build` just pushed. `needs: build` makes
|
||||
# this wait for a clean build+push — if the build fails, deploy never fires,
|
||||
# so the running stack is left untouched rather than torn down for nothing.
|
||||
needs: build
|
||||
runs-on: uom-deploy-runner
|
||||
# Guard against a workflow_dispatch fired from a non-main branch: only ever
|
||||
# deploy the main line to production.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Pull the fresh images and recreate the stack
|
||||
# `pull` grabs the new :latest images the build job published; `down`
|
||||
# then `up -d` recreates the containers on them. Compose only recreates
|
||||
# services whose image digest changed, so the DB stays put.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /home/perry/website
|
||||
docker compose pull
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
80
.gitea/workflows/pr-checks.yml
Normal file
80
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
|
||||
# build or failing test can't reach the deployable branch. Complements
|
||||
# build-images.yml, which runs only AFTER merge (on push to main) to publish
|
||||
# images — this one runs BEFORE merge.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
# • Enable Status Check
|
||||
# • Status check patterns: PR Checks / *
|
||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||
# without needing the dropdown.
|
||||
#
|
||||
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
|
||||
# only Node (no Docker socket), and the server tests stub their models + point the
|
||||
# DB pool at a dead port, so no MariaDB service is required.
|
||||
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
- name: Install server deps
|
||||
run: npm ci --prefix server
|
||||
- name: Run server tests
|
||||
run: npm test --prefix server
|
||||
- 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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
- name: Install client deps
|
||||
run: npm ci --prefix client
|
||||
- name: Run client tests
|
||||
# Pure-logic unit tests on Node's built-in runner (no browser/DOM).
|
||||
run: npm test --prefix client
|
||||
- name: Build client
|
||||
run: npm run build --prefix client
|
||||
|
||||
bot-install:
|
||||
# No tests/build to run; a clean install still catches a broken or
|
||||
# out-of-sync lockfile before it ships in the bot image.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: bot/package-lock.json
|
||||
- name: Install bot deps
|
||||
run: npm ci --prefix bot
|
||||
87
.gitea/workflows/sonarqube.yml
Normal file
87
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
# Run SonarQube static analysis against the code that just landed on `main` and
|
||||
# report the results to the self-hosted SonarQube server for review. This is
|
||||
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
|
||||
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
|
||||
# (which gates PRs) and build-images.yml (which ships images) — this one only
|
||||
# feeds the dashboard.
|
||||
#
|
||||
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
|
||||
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
|
||||
# My Account → Security in SonarQube for the
|
||||
# runic-gateway-website project (or a global one).
|
||||
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
|
||||
# http://192.168.0.56:9000
|
||||
# (kept as a variable, not committed, so the internal address stays out of git.)
|
||||
#
|
||||
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
|
||||
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
|
||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||
# dashboard when you want to.
|
||||
|
||||
name: SonarQube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Allow re-running the analysis on demand from the Actions tab.
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sonarqube-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out (full history for accurate new-code + blame)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# SonarQube uses git history to attribute issues to authors and to
|
||||
# compute "new code". A shallow clone degrades both.
|
||||
fetch-depth: 0
|
||||
|
||||
# SonarQube runs static analysis only — it never executes the test suite,
|
||||
# so we must produce a coverage report ourselves and hand it to the
|
||||
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
|
||||
# Node's built-in `lcov` coverage reporter needs Node >= 22.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
- name: Install server deps
|
||||
run: npm ci --prefix server
|
||||
|
||||
- name: Generate server test coverage (LCOV)
|
||||
# Run from the repo root (not `--prefix server`) so the LCOV `SF:` paths
|
||||
# are emitted as `server/src/...`, matching sonar.sources and letting the
|
||||
# scanner resolve them against the project base dir. The server tests stub
|
||||
# their models and point the DB pool at a dead port, so no MariaDB is needed.
|
||||
run: |
|
||||
mkdir -p server/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
|
||||
server/test/*.test.js
|
||||
|
||||
- name: Generate client test coverage (LCOV)
|
||||
# The client's pure-logic modules (lib/, api/, data/) are plain ESM with no
|
||||
# browser/DOM deps, so they run on the same built-in runner. Run from the
|
||||
# repo root so the `SF:` paths come out as `client/src/...`. No `npm ci`:
|
||||
# the tested modules import only relative files + Node built-ins.
|
||||
run: |
|
||||
mkdir -p client/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
|
||||
client/test/*.test.js
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||
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
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -6,6 +6,10 @@ server/node_modules/
|
||||
# build output
|
||||
client/dist/
|
||||
|
||||
# test coverage (generated in CI for SonarQube)
|
||||
server/coverage/
|
||||
coverage/
|
||||
|
||||
# env / secrets
|
||||
.env
|
||||
*.env
|
||||
@@ -17,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/
|
||||
|
||||
@@ -31,5 +53,8 @@ Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# local planning docs (not part of the tracked codebase)
|
||||
.plans/
|
||||
|
||||
# scratch / temp scripts
|
||||
_*.ps1
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
# UOMysticmoon Website — Backend Design
|
||||
|
||||
> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
|
||||
> This document is the contract the later phases build against.
|
||||
|
||||
Public contact email: **UOMysticmoon@gmail.com**
|
||||
|
||||
---
|
||||
|
||||
## 1. Stack & top-level decisions
|
||||
|
||||
| Concern | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| Runtime | Node.js + Express | serverlinkr pattern |
|
||||
| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) |
|
||||
| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel |
|
||||
| Frontend | React + Vite, same repo, served by Express in prod | spec |
|
||||
| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr |
|
||||
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
|
||||
|
||||
**Adapting serverlinkr → this project**
|
||||
- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them.
|
||||
- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth".
|
||||
- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Folder structure
|
||||
|
||||
Skeleton from the spec, with a small number of justified additions marked **(+)**.
|
||||
|
||||
```
|
||||
server/
|
||||
.env.example
|
||||
package.json
|
||||
db/
|
||||
schema.sql (+) DDL, also auto-run by the MariaDB container
|
||||
seed.js (+) seed wiki pages, default settings, first admin
|
||||
src/
|
||||
server.js bootstrap: ensure schema, then listen on 0.0.0.0
|
||||
app.js express app + middleware wiring
|
||||
router/
|
||||
api.router.js mounts /v1
|
||||
v1/
|
||||
v1.router.js mounts /auth /public /admin
|
||||
auth/ auth.routes.js + auth.controller.js
|
||||
public/ public.routes.js + public.controller.js
|
||||
admin/ admin.routes.js + admin.controller.js
|
||||
model/
|
||||
users/ users.model.js + users.db.js
|
||||
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
|
||||
wiki/ wiki.model.js + wiki.db.js
|
||||
settings/ settings.model.js + settings.db.js
|
||||
activity/ activity.model.js + activity.db.js (+) admin activity log
|
||||
middleware/ (+)
|
||||
siteMode.js LIVE/MAINTENANCE gate for public content
|
||||
noindex.js X-Robots-Tag: noindex,nofollow on admin
|
||||
rateLimit.js login limiter
|
||||
validate.js express-validator error handler
|
||||
utils/
|
||||
auth.js JWT sign/verify, isLoggedIn middleware
|
||||
db.js MariaDB pool + ensureSchema()
|
||||
mailer.js (+) nodemailer; mailto fallback if SMTP unset
|
||||
client/ built in Phase 2/3 (React + Vite)
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.env.example
|
||||
.gitignore
|
||||
```
|
||||
|
||||
**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode
|
||||
gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four
|
||||
listed models/two utils. They're isolated in `middleware/` + one `activity` model +
|
||||
`utils/mailer.js`, and the spec explicitly says the layout is "expandable."
|
||||
|
||||
---
|
||||
|
||||
## 3. Database schema (MariaDB)
|
||||
|
||||
`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as
|
||||
`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`.
|
||||
|
||||
### users
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| username | VARCHAR(32) UNIQUE NOT NULL | |
|
||||
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
|
||||
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
| last_login_at | DATETIME NULL | shown in user management |
|
||||
|
||||
### posts — one table, four categories
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
|
||||
| title | VARCHAR(200) NOT NULL | |
|
||||
| slug | VARCHAR(220) NULL | optional clean URL |
|
||||
| excerpt | VARCHAR(400) NULL | list teaser |
|
||||
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
|
||||
| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere |
|
||||
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
|
||||
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
|
||||
| published_at | DATETIME NULL | set when first published; list order |
|
||||
|
||||
Index: `(category, published, published_at DESC)`.
|
||||
|
||||
### wiki_pages
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` |
|
||||
| title | VARCHAR(200) NOT NULL | |
|
||||
| body | MEDIUMTEXT NULL | markdown/HTML |
|
||||
| updated_by | INT NULL FK→users(id) | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`.
|
||||
|
||||
### settings — key/value, expandable
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| `key` | VARCHAR(64) PK | |
|
||||
| value | TEXT NULL | |
|
||||
| updated_by | INT NULL FK→users(id) | |
|
||||
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | |
|
||||
|
||||
Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
|
||||
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
|
||||
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
|
||||
|
||||
### activity_log — append-only
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| user_id | INT NULL FK→users(id) | |
|
||||
| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` |
|
||||
| detail | TEXT NULL | JSON string of what changed |
|
||||
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
## 4. API contract
|
||||
|
||||
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
||||
accepts `Authorization: Bearer` for API testing).
|
||||
|
||||
### /auth (auth.routes.js → auth.controller.js)
|
||||
| Method | Path | Auth | Body | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
|
||||
| POST | `/logout` | cookie | — | clear cookie |
|
||||
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
|
||||
|
||||
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
|
||||
admins are created under `/admin/users`.
|
||||
|
||||
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
|
||||
| GET | `/status` | status message + current mode |
|
||||
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
|
||||
| GET | `/posts/:category/:idOrSlug` | single published post |
|
||||
| GET | `/wiki` | list of pages (slug + title) |
|
||||
| GET | `/wiki/:slug` | single page |
|
||||
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
|
||||
|
||||
Public content GETs pass through the **siteMode** gate (§5).
|
||||
|
||||
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
|
||||
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
|
||||
| GET | `/posts?category=` | all posts incl. unpublished |
|
||||
| POST | `/posts` | create |
|
||||
| GET | `/posts/:id` | one |
|
||||
| PUT | `/posts/:id` | edit |
|
||||
| DELETE | `/posts/:id` | delete |
|
||||
| PATCH | `/posts/:id/publish` | `{published}` toggle (sets `published_at`) |
|
||||
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
|
||||
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
||||
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
||||
|
||||
Every admin write logs to `activity_log`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Site mode (LIVE / MAINTENANCE)
|
||||
|
||||
State in `settings.site_mode` (`live`|`maintenance`), default **maintenance**.
|
||||
|
||||
`middleware/siteMode.js`, applied only to **public content** routes:
|
||||
- `live` → pass through.
|
||||
- `maintenance` → respond **503** with `{mode:"maintenance", message}` **unless** the request
|
||||
carries a valid admin cookie (admin preview). This hides content server-side, not just in
|
||||
the UI.
|
||||
|
||||
Always reachable regardless of mode: static assets / SPA shell, `/api/v1/auth/*`, all
|
||||
`/api/v1/admin/*`. So admin login + panel + the maintenance "coming soon" page always load.
|
||||
|
||||
**Client behavior (Phase 3):** reads `GET /public/settings`; if `maintenance` and not an
|
||||
admin previewing, render the polished dark coming-soon page (message + contact email).
|
||||
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
|
||||
|
||||
Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change +
|
||||
who"; `activity_log` provides the history feed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Auth & security
|
||||
|
||||
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
||||
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
|
||||
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
|
||||
- **No directory browsing** (express.static doesn't list; no `serve-index`).
|
||||
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
|
||||
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
|
||||
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Email
|
||||
|
||||
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
|
||||
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
|
||||
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
|
||||
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
|
||||
|
||||
---
|
||||
|
||||
## 7.5 Logging & observability
|
||||
|
||||
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
|
||||
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
|
||||
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
|
||||
|
||||
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
|
||||
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
|
||||
`/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL`
|
||||
(default `debug`, so the file keeps a complete record while the console stays readable).
|
||||
Toggle with `LOG_TO_FILE`. The stream is flushed on graceful shutdown.
|
||||
- **HTTP access logs** via morgan piped into the logger: real client IP (`trust proxy`),
|
||||
authenticated admin username, method, URL, status, response time, size.
|
||||
- **Captured events**: startup config banner, schema/seed steps, login success/failure,
|
||||
rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
|
||||
stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
|
||||
logged. `unhandledRejection`/`uncaughtException` are caught and logged.
|
||||
|
||||
## 8. Deployment
|
||||
|
||||
**docker-compose.yml** — two services on a private network:
|
||||
- `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume
|
||||
`dbdata:/var/lib/mysql`, mounts `schema.sql` into `/docker-entrypoint-initdb.d`, healthcheck.
|
||||
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
|
||||
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
|
||||
`ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
|
||||
- Volumes: `dbdata`, `uploads`.
|
||||
|
||||
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
|
||||
|
||||
**.env.example** (committed; real `.env` ignored):
|
||||
```
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
DB_PASSWORD=
|
||||
DB_ROOT_PASSWORD=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRES_IN=1d
|
||||
COOKIE_SECURE=true
|
||||
COOKIE_NAME=uomm_token
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
CONTACT_TO=UOMysticmoon@gmail.com
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dependencies (server)
|
||||
|
||||
`express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser,
|
||||
express-rate-limit, express-validator, multer, nodemailer` · dev: `nodemon`.
|
||||
Removed vs serverlinkr: `mongoose, mongodb, connect-mongo, express-session, passport,
|
||||
passport-local`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Spec coverage
|
||||
|
||||
| Spec requirement | Covered by |
|
||||
|---|---|
|
||||
| Public pages (`/`, `/site/*`, `/wiki/*`) | `/public/*` API + Phase-3 SPA routes; content from `posts`/`wiki`/`settings` |
|
||||
| News / 5-on-Friday / Newsletter / Screenshots | `posts` table, `category` column; admin CRUD + publish |
|
||||
| Wiki 8 categories, editable later | `wiki_pages` seeded with 8 slugs; admin CRUD |
|
||||
| Status page | `settings.status_message` + mode via `/public/status` |
|
||||
| Admin dashboard (mode, last change, who) | `/admin/dashboard` + settings stamps + activity log |
|
||||
| Site mode toggle | `PUT /admin/site-mode` + `siteMode` middleware |
|
||||
| Admin activity log | `activity_log` + `/admin/activity` |
|
||||
| Admin user management | `/admin/users` CRUD |
|
||||
| Site settings editing | `/admin/settings` |
|
||||
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
|
||||
| Maintenance page, admin always in, static always loads, admin preview | §5 |
|
||||
| SMTP via env, mailto fallback | §7 |
|
||||
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
|
||||
| Design tokens / hero | reused from existing `assets/css/mysticmoon.css` + hero PNG in Phase 2/3 |
|
||||
| Expandable | key/value settings, role enum, modular routers/models |
|
||||
```
|
||||
133
CODE_OF_CONDUCT.md
Normal file
133
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**whitlocktech@gmail.com**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
100
CONTRIBUTING.md
Normal file
100
CONTRIBUTING.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Contributing to Runic Gateway — Website
|
||||
|
||||
Thanks for your interest in contributing! This repo is the full-stack website
|
||||
(Node.js + Express API, MariaDB, React + Vite SPA). This guide covers how to get
|
||||
set up, the workflow we follow, and the rules for contributions.
|
||||
|
||||
By participating you agree to abide by our
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- **Report a bug** or **request a feature** through the
|
||||
[issue tracker](https://gitea.whitlocktech.com/RunicGateway/website/issues)
|
||||
(issue templates are provided).
|
||||
- **Improve the code or docs** by opening a pull request (see below).
|
||||
- **Never** report a security vulnerability in a public issue — see
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
## Development setup
|
||||
|
||||
**Prerequisites:** Node.js 20+ and npm, plus Docker (for MariaDB).
|
||||
|
||||
The [README](README.md) has the full setup guide. The short version for local
|
||||
development with hot reload:
|
||||
|
||||
```bash
|
||||
# 1. Start a MariaDB the backend can reach
|
||||
docker run -d --name rg-db -p 3306:3306 \
|
||||
-e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic \
|
||||
-e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
|
||||
|
||||
# 2. Backend (terminal 1)
|
||||
cp server/.env.example server/.env # set DB_* , JWT_SECRET, ADMIN_USERNAME/PASSWORD
|
||||
npm run install-all
|
||||
npm run server # nodemon -> http://localhost:3000
|
||||
|
||||
# 3. Frontend (terminal 2)
|
||||
npm run client # Vite -> http://localhost:5173
|
||||
```
|
||||
|
||||
Develop against **http://localhost:5173** (the Vite dev server proxies `/api`).
|
||||
|
||||
### Tests & checks
|
||||
|
||||
Please run the server test suite and make sure the client builds before opening
|
||||
a PR — these are the same checks CI runs on your PR:
|
||||
|
||||
```bash
|
||||
npm test # server tests
|
||||
npm run build # client production build
|
||||
```
|
||||
|
||||
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
|
||||
(`feature/…`, `fix/…`, `docs/…`, `chore/…`).
|
||||
2. Keep changes focused; small PRs are easier to review.
|
||||
3. Push and open a pull request against `main`. Fill out the PR template,
|
||||
including the **AI-assisted contributions** disclosure.
|
||||
4. Make sure PR checks (server tests + client build) are green.
|
||||
5. A maintainer will review; address feedback by pushing follow-up commits.
|
||||
|
||||
### Commit messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||
`type(scope): summary` (e.g. `feat(auth): add TOTP challenge step`,
|
||||
`fix(brand): link footer badge to Gitea org`). Common types: `feat`, `fix`,
|
||||
`docs`, `chore`, `refactor`, `test`, `ci`.
|
||||
|
||||
## AI-assisted contributions (disclosure required)
|
||||
|
||||
This project is developed openly with AI assistance, and we ask the same
|
||||
transparency of everyone. **If you used an AI tool** (Claude, Copilot, ChatGPT,
|
||||
Cursor, etc.) to help produce a contribution, you must disclose it:
|
||||
|
||||
- Tick the AI-usage box in the pull-request template and name the tool(s).
|
||||
- Mark AI-authored commits with a trailer, e.g.
|
||||
`Co-Authored-By: Claude <noreply@anthropic.com>` or `Assisted-By: <tool>`.
|
||||
- You remain responsible for every line you submit: review it, understand it,
|
||||
and make sure it is correct and that you have the right to contribute it.
|
||||
|
||||
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are
|
||||
not, and may be closed.
|
||||
|
||||
## License
|
||||
|
||||
Runic Gateway is licensed under the **GNU General Public License v3.0 or later**
|
||||
(see [LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is
|
||||
licensed under the same terms (inbound = outbound) and that you have the right to
|
||||
contribute it.
|
||||
31
CONTRIBUTORS.md
Normal file
31
CONTRIBUTORS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Contributors
|
||||
|
||||
Runic Gateway is built and maintained by the people and tools listed here.
|
||||
Thank you to everyone who has contributed.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **whitlocktech** <whitlocktech@gmail.com> — project lead and maintainer
|
||||
|
||||
## Contributors
|
||||
|
||||
<!--
|
||||
Add yourself here when your contribution is merged — alphabetical by name or
|
||||
handle. One line each:
|
||||
|
||||
- **Name or handle** (optional link) — what you contributed
|
||||
-->
|
||||
|
||||
- _Your name could be here — see [CONTRIBUTING.md](CONTRIBUTING.md)._
|
||||
|
||||
## AI-assisted development
|
||||
|
||||
Parts of Runic Gateway were developed with the assistance of AI coding tools,
|
||||
including **Claude** (Anthropic) via Claude Code. AI-assisted commits are
|
||||
attributed in their commit trailers (e.g. `Co-Authored-By: Claude ...`).
|
||||
|
||||
In keeping with this project's transparency policy, **all contributors must
|
||||
disclose their use of AI tools** on any contribution — see the
|
||||
"AI-assisted contributions" section of [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
Disclosed AI assistance is welcome; undisclosed AI-generated contributions are
|
||||
not.
|
||||
134
HERO_EDITOR.md
134
HERO_EDITOR.md
@@ -1,134 +0,0 @@
|
||||
# UOMysticmoon — Hero Canvas Editor Spec
|
||||
|
||||
> Branch: **`hero-feature`**. Build contract for the WYSIWYG portal-hero editor.
|
||||
> Derived from the design doc *Hero Canvas Editor — Design Document*, **corrected
|
||||
> to match the current codebase** and with the open questions resolved.
|
||||
> Same workflow as the wiki upgrade: design → phased build → verify.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let staff compose the portal hero (background image, overlay opacity, and floating
|
||||
elements — text, CTA buttons, moon, badge, image) in-browser, then preview and
|
||||
publish — no source edits. Layout persists as JSON in the existing `settings` table.
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Scope | **Full v1** — background/overlay, all element types, drag/resize/z-order, draft→preview→publish (built in phases) |
|
||||
| CTA buttons | **First-class `buttons` element type** (independently positioned), not baked into a text block |
|
||||
| First run | **Pre-populate** the canvas with today's hero (headline, subtitle, teaser, CTAs) as editable elements so nothing changes visually until edited |
|
||||
| Drag | **Native Pointer Events** (mouse/touch/pen), zero dependencies |
|
||||
| Font size | Stored in **px** (fixed reference canvas) |
|
||||
| Image compression | **None** server-side; client warns when a file is > ~1 MB |
|
||||
| Preview | `?preview=1` renders the **draft** by reading it through the authenticated admin settings endpoint |
|
||||
| Other pages | Out of scope for v1 (design allows a per-page key later) |
|
||||
|
||||
## 3. Corrections to the design doc (current-code reality)
|
||||
|
||||
1. **Public settings is a whitelist, not `getAll()`.** `GET /api/v1/public/settings`
|
||||
→ `settings.getPublic()` → `PUBLIC_KEYS` in
|
||||
[settings.model.js](server/src/model/settings/settings.model.js). The doc's
|
||||
"no backend changes / picked up automatically" is wrong. **Fix:** add
|
||||
`hero_layout` to `PUBLIC_KEYS` (one line). `hero_layout_draft` stays out
|
||||
(admin-only) — which is why preview reads the draft via `api.admin.getSettings()`.
|
||||
2. **Moon is a reusable component** ([MoonDot.jsx](client/src/components/MoonDot.jsx),
|
||||
props `size`/`glow`), used in logo/login/maintenance — not "only the header."
|
||||
The `moon` element reuses it; it gains an optional `color`.
|
||||
3. **Route vs. nav live in different files.** `/admin/hero` route →
|
||||
[App.jsx](client/src/App.jsx); sidebar link/title → `NAV`/`TITLES` in
|
||||
[AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx).
|
||||
4. **Admin content area is `maxWidth: 1000px`** — the editor canvas renders
|
||||
scaled-to-fit; percentage positions stay faithful.
|
||||
|
||||
Everything else in the doc matches (hardcoded `HERO_BG` + CTAs + `homepage_teaser`
|
||||
in [Portal.jsx](client/src/routes/public/Portal.jsx); `updateSettings` accepts
|
||||
arbitrary keys; `/admin/uploads` exists; default hero asset present; TEXT settings
|
||||
columns — no schema change).
|
||||
|
||||
## 4. Data model — no schema change
|
||||
|
||||
Two `settings` keys (TEXT): `hero_layout` (live) and `hero_layout_draft` (admin).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"background": { "image_url": null, "position_x": "left", "position_y": "center", "size": "cover" },
|
||||
"overlay": { "opacity": 0.72 },
|
||||
"elements": [
|
||||
{ "id": "uuid", "type": "text_block|buttons|moon|badge|image",
|
||||
"x": 50, "y": 42, "z": 1, "anchor": "center", "props": { /* per type */ } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Positions are **% of canvas** (reference width 1080, matching `.shell`), so the
|
||||
layout adapts across viewports without breakpoint data. `version` is validated
|
||||
(`=== 1`) before use; anything else falls back.
|
||||
|
||||
### Element props
|
||||
|
||||
| Type | Props |
|
||||
|---|---|
|
||||
| `text_block` | `lines: [{ text, tag(h1/h2/p/span), fontSize(px), color, weight }]`, `align` |
|
||||
| `buttons` | `items: [{ label, to, variant(primary/ghost) }]`, `align`, `gap` |
|
||||
| `moon` | `size`, `glow`, `color` |
|
||||
| `badge` | `text`, `bgColor`, `textColor`, `borderRadius` |
|
||||
| `image` | `src`, `width`(%), `alt` |
|
||||
|
||||
## 5. Backend changes
|
||||
- **One line:** add `'hero_layout'` to `PUBLIC_KEYS`. No new routes/controllers —
|
||||
layout saves through the existing `PUT /admin/settings`; images via `/admin/uploads`.
|
||||
|
||||
## 6. Frontend changes
|
||||
- **New** `client/src/components/HeroElement.jsx` — renders one element by type
|
||||
(shared by the live portal and the editor canvas).
|
||||
- **New** `client/src/routes/admin/views/HeroEditor.jsx` — canvas + element tray +
|
||||
properties panel; native-pointer drag/resize; background/overlay panel; snap grid;
|
||||
auto-save draft, preview, publish, revert.
|
||||
- **Edit** [Portal.jsx](client/src/routes/public/Portal.jsx) — parse `hero_layout`
|
||||
(or draft when `?preview=1` + admin), render elements, fall back to a
|
||||
`DEFAULT_LAYOUT` built from today's hero so the page is unchanged until edited.
|
||||
- **Edit** [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx) (nav) +
|
||||
[App.jsx](client/src/App.jsx) (route `/admin/hero`).
|
||||
- **Edit** [MoonDot.jsx](client/src/components/MoonDot.jsx) — optional `color`.
|
||||
- **No** `client/src/api/client.js` changes needed beyond what exists
|
||||
(`admin.updateSettings`, `admin.getSettings`, `admin.upload`).
|
||||
|
||||
## 7. Phased build (each phase: build → verify in preview → commit)
|
||||
|
||||
- **Phase 0 — Spec** ✅ this document.
|
||||
- **Phase 1 — Data path & renderer** ✅ (verified 2026-06-28). `hero_layout`
|
||||
whitelisted; `HeroElement.jsx`; Portal renders the layout with a `DEFAULT_LAYOUT`
|
||||
fallback. Default render matches the old hero; publishing a layout re-renders;
|
||||
draft key not exposed publicly. Shared helpers moved to `client/src/lib/heroLayout.js`.
|
||||
- **Phase 2 — Editor shell + background/overlay** ✅ (verified 2026-06-28).
|
||||
`/admin/hero` view + sidebar nav; canvas live-preview; background upload + 3×3
|
||||
position + overlay opacity; debounced draft auto-save; publish; `?preview=1`
|
||||
reads the draft (admin) with a banner; revert. Verified: overlay/position update
|
||||
the canvas, auto-save writes the draft, publish writes live, preview shows the
|
||||
draft while the normal portal shows live.
|
||||
- **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
|
||||
2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
|
||||
native Pointer Events drag (% of canvas); Delete key + panel delete; z-order
|
||||
(send back / bring forward); text_block line editor (text/tag/size/color/bold,
|
||||
add/remove lines, align) and buttons editor (label/path/variant, add/remove).
|
||||
Verified: select shows the line editor, editing a line updates the canvas live,
|
||||
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
|
||||
- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
|
||||
Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
|
||||
text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
|
||||
moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
|
||||
until a file is chosen. Verified: each type adds + edits, resize moved a moon
|
||||
64→104px, snap grid shows, and a published moon+badge render on the live portal.
|
||||
|
||||
**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
|
||||
Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
|
||||
handle for now), per-viewport layouts, server-side image compression.
|
||||
|
||||
## 8. Edge cases (from the doc, carried forward)
|
||||
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
|
||||
- Element ids via `crypto.randomUUID()` (never array index).
|
||||
- Empty `elements` → render `DEFAULT_LAYOUT` so the hero is never blank.
|
||||
- Last-write-wins on concurrent admin edits (acceptable for this shard).
|
||||
- Client-side warning for background files > ~1 MB (no hard block; 8 MB server cap).
|
||||
674
LICENSE.md
Normal file
674
LICENSE.md
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
497
README.md
497
README.md
@@ -1,18 +1,31 @@
|
||||
# UOMysticmoon Website
|
||||
# Runic Gateway Website
|
||||
|
||||
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
|
||||
shard — a full-stack app in one repo:
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
|
||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, JWT-in-cookie auth.
|
||||
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
|
||||
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
||||
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
||||
|
||||
A full-stack app in one repo:
|
||||
|
||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||
- **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production.
|
||||
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
||||
|
||||
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||
The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Project structure](#project-structure)
|
||||
- [Prerequisites](#prerequisites)
|
||||
@@ -23,10 +36,108 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
- [First admin & site mode](#first-admin--site-mode)
|
||||
- [Pages & routes](#pages--routes)
|
||||
- [API endpoints](#api-endpoints)
|
||||
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Security](#security)
|
||||
- [Logging](#logging)
|
||||
- [Deployment behind Pangolin](#deployment-behind-pangolin)
|
||||
- [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,34 +146,37 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
||||
| Auth | JWT in an httpOnly cookie, bcrypt password hashing |
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
|
||||
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
UOMSITE/
|
||||
website/
|
||||
├─ server/ Express API
|
||||
│ ├─ src/
|
||||
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||
│ │ ├─ app.js middleware + static SPA + routes
|
||||
│ │ ├─ router/v1/ auth / public / admin route groups
|
||||
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db)
|
||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · validate
|
||||
│ │ └─ utils/ auth (JWT/cookies) · db (pool) · mailer · logger
|
||||
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
|
||||
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
|
||||
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
|
||||
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
|
||||
│ ├─ db/ schema.sql + seed.js
|
||||
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
|
||||
│ └─ .env.example
|
||||
├─ client/ React + Vite SPA
|
||||
│ ├─ src/
|
||||
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
||||
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
||||
│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users) + editors
|
||||
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, …
|
||||
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
|
||||
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
|
||||
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
||||
│ │ └─ styles/theme.css design tokens
|
||||
@@ -86,8 +200,10 @@ UOMSITE/
|
||||
|
||||
### Option A — Docker Compose (full stack)
|
||||
|
||||
The simplest way to run everything. The image installs server deps, **builds the React client**,
|
||||
and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are
|
||||
`docker-compose.yml` is **production-shaped**: it *pulls* the prebuilt `app` and `bot` images from
|
||||
the Gitea container registry (published by `.gitea/workflows/build-images.yml` on every merge to
|
||||
`main`) — it never builds. Each image already bundles the server deps and the built React client,
|
||||
which Express serves. MariaDB runs in its own container; tables + defaults + the first admin are
|
||||
created automatically on first boot.
|
||||
|
||||
```bash
|
||||
@@ -97,7 +213,9 @@ cp .env.example .env
|
||||
# JWT_SECRET (a long random string)
|
||||
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
|
||||
|
||||
docker compose up -d --build
|
||||
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
|
||||
# pin a specific build (reproducible deploy / rollback):
|
||||
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
- App: **http://localhost:3000** (binds `0.0.0.0`)
|
||||
@@ -105,6 +223,16 @@ docker compose up -d --build
|
||||
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
|
||||
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
|
||||
|
||||
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
|
||||
the dev file, which adds `build:` back:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Keeping `build:` out of the base file means a production host can only ever pull — it can never
|
||||
accidentally build.
|
||||
|
||||
### Option B — Local development (hot reload)
|
||||
|
||||
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
|
||||
@@ -113,14 +241,14 @@ to the backend, so the SPA stays same-origin (cookies work).
|
||||
**1. Start a MariaDB the backend can reach** (published on `localhost:3306`):
|
||||
|
||||
```bash
|
||||
docker run -d --name uomm-db -p 3306:3306 -e MARIADB_DATABASE=uomysticmoon -e MARIADB_USER=uomm -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
|
||||
docker run -d --name rg-db -p 3306:3306 -e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
|
||||
```
|
||||
|
||||
**2. Configure + start the backend** (terminal 1):
|
||||
|
||||
```bash
|
||||
cp server/.env.example server/.env
|
||||
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass,
|
||||
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=runic, DB_PASSWORD=devpass,
|
||||
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
|
||||
npm run install-server
|
||||
npm run server # nodemon → http://localhost:3000
|
||||
@@ -187,7 +315,10 @@ npm start # node server → serves API + SPA at http://localhost:3
|
||||
| `/admin/wiki` | Wiki pages CRUD |
|
||||
| `/admin/settings` | Site settings |
|
||||
| `/admin/activity` | Activity log |
|
||||
| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
|
||||
| `/admin/auth-providers` | Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
|
||||
| `/admin/users` | User management |
|
||||
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,12 +326,152 @@ npm start # node server → serves API + SPA at http://localhost:3
|
||||
|
||||
| Group | Base | Auth |
|
||||
|---|---|---|
|
||||
| Auth | `/api/v1/auth` (`login`, `logout`, `me`) | cookie |
|
||||
| Auth (web) | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie |
|
||||
| Auth (mobile) | `/api/v1/auth/mobile` (`login`, `refresh`, `logout`) | bearer (access + refresh tokens) |
|
||||
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `users`) | cookie (admin) |
|
||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
||||
| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
|
||||
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
|
||||
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
|
||||
|
||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
|
||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||
See [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
||||
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
||||
|
||||
---
|
||||
|
||||
## API documentation (Swagger)
|
||||
|
||||
The full API is documented as an **OpenAPI 3.0** spec and served with **Swagger UI**:
|
||||
|
||||
| URL | What |
|
||||
|---|---|
|
||||
| `http://localhost:3000/api/docs` | Interactive Swagger UI (try-it-out, auth) |
|
||||
| `http://localhost:3000/api/docs.json` | Raw OpenAPI 3.0 spec (JSON) |
|
||||
|
||||
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
|
||||
groups) with its summary, parameters, request body, security requirement, and the response codes it
|
||||
actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `429` rate limits, …).
|
||||
|
||||
**Authentication in the UI** — click **Authorize** and provide either:
|
||||
|
||||
- `cookieAuth` — the session cookie (name `rg_token`, configurable via `COOKIE_NAME`; set automatically in the browser after
|
||||
`POST /api/v1/auth/login`), or
|
||||
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
||||
`Authorization: Bearer <token>`).
|
||||
|
||||
**Regenerating the spec** — the spec is generated from `#swagger.*` annotations next to each route
|
||||
(`server/src/router/**`) plus the shared definitions in `server/swagger/swagger.js`
|
||||
([swagger-autogen](https://github.com/davibaltar/swagger-autogen)). The output
|
||||
`server/swagger/swagger-output.json` is committed so the docs work with no build step. After adding
|
||||
or changing a route, regenerate it:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run swagger # → server/swagger/swagger-output.json
|
||||
```
|
||||
|
||||
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||
not crash).
|
||||
|
||||
### 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)
|
||||
|
||||
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
|
||||
runs next to the ServUO shard. Its source lives in a separate repo:
|
||||
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
|
||||
exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
|
||||
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
|
||||
to it.
|
||||
|
||||
### How it works
|
||||
|
||||
```
|
||||
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
|
||||
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
||||
```
|
||||
|
||||
- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
|
||||
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
|
||||
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
|
||||
the API — it is never returned to any client and never sent to the browser. Every call the backend
|
||||
makes carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header (a protocol
|
||||
mismatch fails fast with `409` instead of being mis-parsed).
|
||||
- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
|
||||
and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
|
||||
`vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
|
||||
`link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
|
||||
each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
|
||||
kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
|
||||
ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
|
||||
restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
|
||||
the sidecar's `/history`.
|
||||
- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
|
||||
`/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
|
||||
commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
|
||||
throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
|
||||
degrades to a `503`/retry banner instead of a 500.
|
||||
- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
|
||||
Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
|
||||
**admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
|
||||
attempts, IPs). Sensitive kinds can never leak onto the public channel.
|
||||
|
||||
### Account linking
|
||||
|
||||
A player (or staff member) proves ownership of a game account without sharing any game credentials:
|
||||
|
||||
1. In game, the player runs **`[link`** and receives a one-time code.
|
||||
2. On the website (Player portal, or Admin → Account for staff) they enter the code.
|
||||
3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the
|
||||
game account with the website user id, and mirrors the link locally in `shard_account_links`.
|
||||
|
||||
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
|
||||
are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
|
||||
character**; players and editor/moderator staff are limited to their own linked accounts.
|
||||
|
||||
### What each audience sees
|
||||
|
||||
| Surface | Endpoints | Who | Data |
|
||||
|---|---|---|---|
|
||||
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
||||
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
||||
|
||||
The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the
|
||||
shard is offline), every shard surface degrades gracefully — the public page still renders, showing
|
||||
the shard as offline.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,29 +483,121 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
|---|---|---|
|
||||
| `NODE_ENV` | `production` | |
|
||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
|
||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||
| `JWT_SECRET` | — | **required** — long random string |
|
||||
| `JWT_EXPIRES_IN` | `1d` | token + cookie lifetime |
|
||||
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
||||
| `COOKIE_NAME` | `uomm_token` | |
|
||||
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
||||
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
||||
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + proxy HTTPS) |
|
||||
| `COOKIE_NAME` | `rg_token` | changing it on a live instance invalidates existing sessions |
|
||||
| `BRAND_*` | Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see [Branding](#branding) |
|
||||
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
|
||||
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
|
||||
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
|
||||
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
|
||||
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
|
||||
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
|
||||
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
|
||||
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
|
||||
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
||||
|
||||
---
|
||||
|
||||
## Branding
|
||||
|
||||
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
|
||||
image can run as any shard. With none set, everything renders as **Runic Gateway**.
|
||||
|
||||
| Var | What |
|
||||
|---|---|
|
||||
| `BRAND_NAME` / `BRAND_SHORT_NAME` | display name (full / short-in-prose) |
|
||||
| `BRAND_TAGLINE` / `BRAND_DESCRIPTION` | tagline + meta/OG description |
|
||||
| `BRAND_CONTACT_EMAIL` / `BRAND_URL` | contact + canonical URL (for OG/absolute links) |
|
||||
| `BRAND_ACCENT_COLOR` | theme `--accent` (web) + Discord embed color |
|
||||
| `BRAND_LOGO` / `BRAND_HERO` / `BRAND_FAVICON` | image paths under the `/brand` mount, or absolute URLs |
|
||||
|
||||
**How it flows:** text/colors reach the SPA at runtime through the public settings
|
||||
API (`SiteContext`), so no rebuild is needed; the server templates `index.html`
|
||||
`<title>`/meta/OG/favicon at boot; emails, TOTP issuer, and the Discord bot read
|
||||
`BRAND_*` directly. The admin-editable **site title** and **contact email**
|
||||
settings override `BRAND_NAME` / `BRAND_CONTACT_EMAIL` when set. Image assets are
|
||||
delivered from the `./brand` bind-mount (see `brand/README.md`).
|
||||
|
||||
**UOMysticmoon** is the first instance — [`.env.uomysticmoon.example`](.env.uomysticmoon.example)
|
||||
holds the exact `BRAND_*` + infra (`DB_NAME`/`DB_USER`/`COOKIE_NAME`) pinning to
|
||||
run this repo as UOMysticmoon.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected) · bcrypt hashing · login &
|
||||
contact rate limiting · `express-validator` on writes · `helmet` · admin routes `noindex` +
|
||||
`robots.txt` disallow · `trust proxy` for correct client IPs behind Pangolin · first admin seeded
|
||||
from env (no hardcoded credentials) · `.env` git-ignored. Passwords and request bodies are never
|
||||
logged. SMTP is optional — the contact form falls back to a `mailto:` link when unconfigured.
|
||||
**Session & authorization**
|
||||
|
||||
- All auth flows go through one **session service** (`server/src/auth/`): controllers call
|
||||
`sessionService.createSession(user, authMethod)` and middleware calls `validateSession()`, so web
|
||||
cookies, mobile bearer tokens, and SSO all produce the *same* authenticated session model.
|
||||
`utils/auth.js` remains a thin backward-compat facade.
|
||||
- JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected), bcrypt password hashing.
|
||||
- Admin routes are **re-validated against the database on every request**, so a demoted or deleted
|
||||
user loses access immediately instead of keeping their old role until the token expires.
|
||||
- **Role-based authorization** — admin-only endpoints (users, site mode, settings, auth providers)
|
||||
are gated by a `requireRole` check, so a lower-privilege editor can't reach them.
|
||||
|
||||
**Mobile bearer auth**
|
||||
|
||||
- Native clients use `/api/v1/auth/mobile/*`: a short-lived **access token** (bearer JWT, validated
|
||||
by the same middleware as the cookie) plus a long-lived, **server-stored, revocable refresh
|
||||
token** that is **rotated on every refresh** (a replayed refresh token is single-use). Refresh
|
||||
tokens are stored **hashed** (never in the clear); logout revokes one or all. Mobile login reuses
|
||||
the same bot-scoring + backoff defenses as web, with single-request TOTP.
|
||||
|
||||
**Single sign-on (OAuth2 / OIDC)**
|
||||
|
||||
- Pluggable providers — built-in **Google** and **Discord** (endpoints fixed in code; admins supply
|
||||
only client id/secret) plus fully-configurable **custom OIDC/OAuth2** providers, managed from the
|
||||
**Authentication** admin panel. Only `enabled` + fully-configured providers are shown to users.
|
||||
- **Link-only** by policy: an SSO login succeeds *only* if the external identity is already linked to
|
||||
an existing account (linked by the user from **Account**). External identities are **never
|
||||
auto-provisioned** — no one gains access without an account you created.
|
||||
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
|
||||
**PKCE**; OAuth client secrets are **encrypted at rest** (AES-256-GCM) and never returned to any
|
||||
client. SSO logins go through the same `sessionService`, so login/activity logging, RBAC, and bot
|
||||
protection are identical to a local login.
|
||||
|
||||
**Login hardening**
|
||||
|
||||
- **Optional per-user TOTP two-factor** (opt-in, self-service on `/admin/account`). When enabled,
|
||||
the password step issues only a short-lived, non-session `stage:'totp'` challenge; a session
|
||||
cookie is granted only after the second factor verifies.
|
||||
- **Login throttling** — `express-slow-down` + a hard rate cap + a separate per-IP exponential
|
||||
backoff, with generic error messages that don't reveal whether the username exists.
|
||||
- **Honeypot** field on the login form; submissions that fill it are treated as bots.
|
||||
- **Bot-scoring + automatic IP ban** — weighted scoring of CMS-scanner paths and junk 404s (with a
|
||||
periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the
|
||||
score. Admins get visibility into this on the **Bot Activity** panel: currently banned IPs and a
|
||||
recent-events feed (in-memory, most-recent-first), plus a logged emergency **unban** for false
|
||||
positives — read + unban only, not a scoring-config surface.
|
||||
|
||||
**Uploads & input**
|
||||
|
||||
- Uploaded file extensions are derived from the **validated mimetype**, not the client-supplied
|
||||
filename (prevents a disguised-extension upload).
|
||||
- `express-validator` on all writes; usernames are validated **and** uniqueness-checked on update.
|
||||
|
||||
**Platform**
|
||||
|
||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
||||
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
|
||||
contact form falls back to a `mailto:` link when unconfigured.
|
||||
|
||||
---
|
||||
|
||||
@@ -258,10 +621,60 @@ bind-mounted to `./logs/app.log` and `docker compose logs -f app` shows the cons
|
||||
|
||||
---
|
||||
|
||||
## Deployment behind Pangolin
|
||||
## Deployment behind a reverse proxy
|
||||
|
||||
`docker compose up -d --build` exposes the `app` container on `0.0.0.0:3000` (no `127.0.0.1`
|
||||
binding) so Pangolin can reach it. Point a Pangolin resource at `app:3000`. Because `COOKIE_SECURE`
|
||||
defaults to `auto`, the admin login works both directly via the LAN IP over HTTP **and** through
|
||||
Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network
|
||||
(no published port by default); data persists in the `dbdata` volume, uploads in `uploads`.
|
||||
binding) so a reverse proxy — Pangolin, Nginx, Caddy, Traefik, etc. — can reach it. Point the
|
||||
proxy at `app:3000` (or the host's `:3000` if the proxy runs outside Compose) and terminate TLS
|
||||
there. Because `COOKIE_SECURE` defaults to `auto`, the admin login works both directly via the
|
||||
LAN IP over HTTP **and** through the proxy over HTTPS — no config change needed. MariaDB stays on
|
||||
the private Compose network (no published port by default); data persists in the `dbdata` volume,
|
||||
uploads in `uploads`.
|
||||
|
||||
Set `TRUST_PROXY` so Express reads the real client IP from the proxy's `X-Forwarded-For` header
|
||||
(see [Environment variables](#environment-variables)) — required for rate limiting, bot scoring,
|
||||
and correct logging. Forward the standard `X-Forwarded-For` and `X-Forwarded-Proto` headers from
|
||||
your proxy.
|
||||
|
||||
Minimal proxy examples:
|
||||
|
||||
```nginx
|
||||
# Nginx
|
||||
location / {
|
||||
proxy_pass http://app:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
```caddy
|
||||
# Caddy — Caddyfile (automatic HTTPS; forwards X-Forwarded-* by default)
|
||||
your.domain {
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Pangolin:** create a resource targeting `app:3000`; it forwards the required headers and
|
||||
terminates HTTPS out of the box, so no extra configuration is needed.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Runic Gateway is free software, licensed under the **GNU General Public License
|
||||
v3.0 or later** — see [LICENSE.md](LICENSE.md).
|
||||
|
||||
Copyright (C) 2026 Runic Gateway
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later
|
||||
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
|
||||
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
|
||||
the **AI-usage disclosure** requirement) and our
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
50
SECURITY.md
Normal file
50
SECURITY.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Security Policy
|
||||
|
||||
Thank you for helping keep Runic Gateway and its users safe.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public issues, pull
|
||||
requests, or the wiki.** A public report tips off attackers before a fix is
|
||||
available.
|
||||
|
||||
Instead, report privately by email to:
|
||||
|
||||
**whitlocktech@gmail.com**
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
- The repository and component affected.
|
||||
- The type of issue (e.g. authentication bypass, injection, secret exposure,
|
||||
remote code execution, denial of service).
|
||||
- Step-by-step instructions to reproduce, and a proof-of-concept if you have one.
|
||||
- The impact — what an attacker could do with it.
|
||||
- Any suggested remediation.
|
||||
|
||||
You will receive an acknowledgement of your report, typically within a few days.
|
||||
We will keep you informed as we investigate and work toward a fix, and we are
|
||||
happy to credit you in the release notes once the issue is resolved (let us know
|
||||
if you would prefer to remain anonymous).
|
||||
|
||||
## Scope
|
||||
|
||||
Runic Gateway is a self-hosted platform made up of several components:
|
||||
|
||||
| Component | Repo | Network exposure |
|
||||
|---|---|---|
|
||||
| Website (site + admin + API) | `RunicGateway/website` | Internet-facing (behind a reverse proxy) |
|
||||
| uo-link sidecar | `RunicGateway/link` | The only network-facing part of the game bridge |
|
||||
| ServUO plugin | `RunicGateway/servuo-plugins` | Loopback only — dials the sidecar on `127.0.0.1` |
|
||||
| Documentation | `RunicGateway/docs` | Content only |
|
||||
|
||||
Because instances are self-hosted, the security of any given deployment also
|
||||
depends on how it is configured and operated — strong secrets (`JWT_SECRET`,
|
||||
`SECRET_ENC_KEY`, database and admin passwords), a correctly configured reverse
|
||||
proxy and `TRUST_PROXY`, and keeping the shard itself unreachable from the
|
||||
internet (only the sidecar should be exposed). See each repo's README for the
|
||||
security model.
|
||||
|
||||
## Supported versions
|
||||
|
||||
This project is developed continuously and does not maintain long-term release
|
||||
branches. Security fixes land on `main`; please run a recent build.
|
||||
366
WIKI_UPGRADE.md
366
WIKI_UPGRADE.md
@@ -1,366 +0,0 @@
|
||||
# UOMysticmoon Website — Wiki Upgrade Spec
|
||||
|
||||
> Branch: **`wiki-upgrade`**. This document is the contract for upgrading the CMS
|
||||
> wiki from a flat single-table page store into a feature-complete wiki.
|
||||
> It follows the project workflow: **design (this doc) → build in phases → verify**.
|
||||
>
|
||||
> Companion to [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md); reuses its stack, auth,
|
||||
> logging, and Docker decisions unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & scope
|
||||
|
||||
Turn the wiki into something that behaves like a typical wiki, while staying inside
|
||||
the existing Node/Express + MariaDB + React/Vite architecture and the **staff-only**
|
||||
auth model (admin/editor — no new roles, no public contributions).
|
||||
|
||||
**In scope**
|
||||
|
||||
| Feature | Summary |
|
||||
|---|---|
|
||||
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
|
||||
| Sanitization | Server-side allowlist on save **and** client-side on render (fixes today's stored-XSS gap) |
|
||||
| Categories / sections | First-class `wiki_categories` table; replaces hardcoded frontend blurbs |
|
||||
| Drafts & publish | `published` + `published_at`, mirroring the `posts` pattern |
|
||||
| Tags | Many-to-many tags with filtering |
|
||||
| Internal links | `[[slug]]`-style links authored in the editor; red-link detection |
|
||||
| Backlinks | "Linked from" list, maintained on save |
|
||||
| Inline images | Reuse/generalize the existing multer upload for in-body images |
|
||||
| Search | MariaDB `FULLTEXT` over title + body |
|
||||
| Revision history | Per-save snapshots with view / diff / restore |
|
||||
|
||||
**Out of scope (this branch)**
|
||||
|
||||
- Public/player editing or suggestion workflow, moderation/review queues.
|
||||
- New roles or per-page ACLs (all staff with a login can edit all pages).
|
||||
- Real-time collaborative editing, comments/discussion pages, file attachments
|
||||
other than images, page templates/transclusion, multilingual pages.
|
||||
|
||||
**Decisions locked from planning**
|
||||
|
||||
- Editor: **TipTap**, storing **HTML** (not Markdown, not JSON).
|
||||
- Search: **MariaDB FULLTEXT** (no new infrastructure).
|
||||
- Revision history and search are **included** (recommended additions beyond the
|
||||
minimum requested set).
|
||||
- Authoring is **admin + editor** (`isLoggedIn`); no anonymous edits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (baseline being replaced)
|
||||
|
||||
| Layer | Today | File |
|
||||
|---|---|---|
|
||||
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
|
||||
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
|
||||
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
|
||||
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
|
||||
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
|
||||
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
|
||||
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
|
||||
|
||||
**Known issues this upgrade resolves**
|
||||
|
||||
- **Stored XSS**: body is raw HTML rendered with `dangerouslySetInnerHTML` and never
|
||||
sanitized ([WikiArticle.jsx:91](client/src/routes/wiki/WikiArticle.jsx)).
|
||||
- Category blurbs and ordering are **faked in the component** ([Wiki.jsx:11](client/src/routes/wiki/Wiki.jsx)), not data.
|
||||
- No drafts (every save is instantly public), no history, no search, no tags, no links.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
`utf8mb4`, InnoDB throughout. All changes are **additive and idempotent** so
|
||||
`ensureSchema()` upgrades existing databases on boot with no data loss. New columns
|
||||
are nullable or have safe defaults; **existing pages default to `published = 1`** so
|
||||
nothing disappears on deploy.
|
||||
|
||||
### 3.1 `wiki_categories` (new)
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AI | |
|
||||
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `guides` |
|
||||
| title | VARCHAR(200) NOT NULL | |
|
||||
| description | VARCHAR(400) NULL | card teaser on the wiki index |
|
||||
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
|
||||
| created_at / updated_at | DATETIME | standard stamps |
|
||||
|
||||
### 3.2 `wiki_pages` (altered)
|
||||
|
||||
Add to the existing table:
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
|
||||
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
|
||||
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
|
||||
| published_at | DATETIME NULL | set on first publish |
|
||||
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
|
||||
| FULLTEXT idx_wiki_search (title, body) | | search |
|
||||
|
||||
### 3.3 `wiki_tags` + `wiki_page_tags` (new)
|
||||
|
||||
```
|
||||
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
|
||||
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
|
||||
tag_id FK→wiki_tags ON DELETE CASCADE,
|
||||
PRIMARY KEY(page_id, tag_id) )
|
||||
```
|
||||
|
||||
### 3.4 `wiki_links` (new) — backlinks index
|
||||
|
||||
Rebuilt for a page on every save by parsing its body for internal links.
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
|
||||
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
|
||||
| INDEX idx_wiki_links_target (target_slug) | | backlink lookups |
|
||||
|
||||
Backlinks for page X = `SELECT source pages WHERE target_slug = X.slug AND source is published`.
|
||||
|
||||
### 3.5 `wiki_revisions` (new) — history
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AI | |
|
||||
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
|
||||
| title / body / excerpt | snapshot of content at save time | |
|
||||
| category_id | INT NULL | snapshot |
|
||||
| editor_id | INT NULL FK→users(id) | who saved |
|
||||
| change_note | VARCHAR(280) NULL | optional summary |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
|
||||
A revision is written **inside the same transaction** as each page create/update.
|
||||
|
||||
### 3.6 Seed changes
|
||||
|
||||
Rework [seed.js](server/db/seed.js): the current 8 hardcoded pages become **categories**
|
||||
(title + the blurb currently living in the frontend), each seeded idempotently via a new
|
||||
`seedDefaultCategory`. Existing seeded pages are migrated/attached where applicable.
|
||||
`seedDefault` for pages stays `INSERT IGNORE` so reseeding is safe.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend changes
|
||||
|
||||
Keep the `model` (entity) / `db` (SQL) split and the route grouping by access level.
|
||||
|
||||
### 4.1 Models (`server/src/model/wiki/`)
|
||||
|
||||
- `wiki.db.js` — add SQL for: category CRUD; page list with `category`, `published`,
|
||||
`q` (FULLTEXT) filters and ordering; tag upsert + attach/detach; `wiki_links` rebuild;
|
||||
revision insert/list/get; backlink query.
|
||||
- `wiki.model.js` — orchestration. On **create/update** (single transaction):
|
||||
1. sanitize `body` with the allowlist (§6),
|
||||
2. upsert the page,
|
||||
3. insert a `wiki_revisions` snapshot,
|
||||
4. parse body for internal links → rebuild `wiki_links` for the page,
|
||||
5. sync tags.
|
||||
- A small `wiki.links.js` helper: parse internal links out of the saved HTML
|
||||
(anchors written by the editor as `href="/wiki/<slug>"` / a `data-wiki-slug` attr),
|
||||
return the set of target slugs.
|
||||
|
||||
### 4.2 Public API (`/api/v1/public`)
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/wiki/categories` | ordered categories with page counts |
|
||||
| GET | `/wiki?category=&tag=&q=` | **published only**; list/filter/search summaries |
|
||||
| GET | `/wiki/:slug` | page + category + tags + backlinks (published only) |
|
||||
|
||||
Still passes through the `siteMode` maintenance gate like other public content.
|
||||
|
||||
### 4.3 Admin API (`/api/v1/admin`, behind `isLoggedIn` + `noindex`)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/wiki` | all pages incl. drafts (filters: category, tag, q, status) |
|
||||
| GET | `/wiki/:slug` | one page incl. draft, tags, category |
|
||||
| POST | `/wiki` | create (slug, title, body, excerpt, category_id, tags, published) |
|
||||
| PUT | `/wiki/:slug` | update (allows slug rename — see §7) |
|
||||
| PATCH | `/wiki/:slug/publish` | `{published}` toggle, stamps `published_at` |
|
||||
| DELETE | `/wiki/:slug` | delete (cascades revisions/links/tags) |
|
||||
| GET | `/wiki/:slug/revisions` | list snapshots |
|
||||
| GET | `/wiki/:slug/revisions/:id` | one snapshot (for diff/preview) |
|
||||
| POST | `/wiki/:slug/revisions/:id/restore` | restore (writes a new revision) |
|
||||
| GET/POST/PUT/DELETE | `/wiki/categories[...]` | category CRUD + reorder |
|
||||
| GET/POST | `/wiki/tags` | list/create tags |
|
||||
| POST | `/uploads` | generalized image upload (see §4.4) → `{url}` |
|
||||
|
||||
Validation via `express-validator` (slug regex `^[a-z0-9-]+$`, title required, etc.),
|
||||
centralized error handler unchanged. **Every write logs to `activity_log`**
|
||||
(`wiki.create`, `wiki.update`, `wiki.publish`, `wiki.delete`, `wiki.revision.restore`,
|
||||
`wiki.category.*`) following the existing convention.
|
||||
|
||||
### 4.4 Image uploads
|
||||
|
||||
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
|
||||
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
|
||||
the post editor and the wiki editor. Same size/mime limits. No new storage —
|
||||
served from the existing `uploads/` volume.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend changes
|
||||
|
||||
### 5.1 Admin
|
||||
|
||||
- **`WikiEditor.jsx`** — replace the raw-HTML `<textarea>` with a **TipTap** editor:
|
||||
bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, **image insert**
|
||||
(uploads via `/admin/uploads`), and an **internal-link picker** (`[[`-triggered
|
||||
autocomplete over existing slugs; flags red links). Adds: category dropdown, tag
|
||||
input (create-on-type), excerpt field, **Save draft / Publish** actions, and a
|
||||
**History** tab (revision list → preview → diff → restore).
|
||||
- **`WikiAdmin.jsx`** — list gains status (draft/published), category column, and
|
||||
filters; plus a **Categories** manager (CRUD + drag-to-reorder).
|
||||
|
||||
### 5.2 Public
|
||||
|
||||
- **`Wiki.jsx`** — fully data-driven: categories + real excerpts from the API
|
||||
(delete the hardcoded `BLURBS`/`ROMAN` constants), a **search box**, optional
|
||||
tag filter.
|
||||
- **`WikiArticle.jsx`** — keep auto-TOC; add category breadcrumb, tag chips, a
|
||||
**"Linked from"** backlinks section, "last updated by", and **render via DOMPurify**
|
||||
(`dangerouslySetInnerHTML` only after sanitize).
|
||||
|
||||
### 5.3 API client & routes
|
||||
|
||||
- Extend [client/src/api/client.js](client/src/api/client.js) with the new public/admin
|
||||
wiki calls (categories, search params, revisions, tags, uploads).
|
||||
- Add a public search/category route if needed; admin categories view registered in
|
||||
[App.jsx](client/src/App.jsx) under `/admin/wiki` (sub-tab, no new top-level route required).
|
||||
|
||||
### 5.4 Dependencies (new)
|
||||
|
||||
- **client**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`,
|
||||
`@tiptap/extension-image` (+ a small diff lib for history, e.g. `diff`); `dompurify`.
|
||||
- **server**: `sanitize-html`.
|
||||
|
||||
(The client currently ships only React + react-router, so this is the first feature
|
||||
dependency addition — keep the bundle lean, import only the extensions used.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Security
|
||||
|
||||
- **Two-layer sanitization.** Server sanitizes on save with a strict `sanitize-html`
|
||||
allowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt],
|
||||
strong/em, hr, table basics); strips scripts, event handlers, `javascript:` URLs,
|
||||
styles. Client re-sanitizes with DOMPurify before render. The stored value is already
|
||||
clean, so even direct DB edits or future API clients can't inject script.
|
||||
- **Upload safety** unchanged from posts: mime allowlist (png/jpe/gif/webp/avif),
|
||||
8 MB cap, random filenames, served as static files (no execution).
|
||||
- **Authorization**: all mutating wiki/category/tag/upload routes stay behind
|
||||
`isLoggedIn` (admin or editor). Public routes are read-only and published-only.
|
||||
- **No secrets/logging changes**; reuse existing rate-limit, helmet/CSP, noindex.
|
||||
CSP `img-src` already covers `/uploads`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration & backward compatibility
|
||||
|
||||
- Schema migration is additive; run by `ensureSchema()` on boot and shipped in
|
||||
`schema.sql` for fresh containers. Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
|
||||
/ `ADD INDEX` guarded for idempotency (MariaDB 11 supports `IF NOT EXISTS`).
|
||||
- Existing pages: `published` backfills to `1`, `published_at` to `updated_at`,
|
||||
`category_id` left NULL (surface as "Uncategorized" until assigned).
|
||||
- **Slug rename** (new capability): on `PUT` slug change, update the page slug and
|
||||
best-effort rewrite known internal links pointing at the old slug; old slug is not
|
||||
auto-redirected (acceptable for a staff-curated wiki) — note in release notes.
|
||||
- Public API response shape is **extended, not broken**: existing fields
|
||||
(`slug`, `title`, `body`, `updated_at`) remain; new fields are additive, so the
|
||||
current frontend keeps working between phases.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation process (phased)
|
||||
|
||||
Each phase is a self-contained, shippable unit: build → run locally → verify in the
|
||||
browser preview → commit on `wiki-upgrade`. Open a PR into `main` at the end (or per
|
||||
phase if preferred). Do not merge a phase that hasn't been verified.
|
||||
|
||||
### Phase 0 — Branch & scaffolding ✅ (this doc)
|
||||
- `wiki-upgrade` branch created; this spec committed.
|
||||
|
||||
### Phase 1 — Foundation & safety (highest value) ✅
|
||||
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
|
||||
published_at, sort_order, FULLTEXT), update `seed.js`.
|
||||
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
|
||||
public list filtered to published + categories endpoint.
|
||||
- Client: data-driven `Wiki.jsx` (remove hardcoded blurbs); DOMPurify render in
|
||||
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
|
||||
- **Exit check**: existing pages still render; XSS payload in body is neutralized;
|
||||
draft pages hidden from the public list/article.
|
||||
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
|
||||
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
|
||||
the public API and are absent from the public list while visible in admin; public
|
||||
index is data-driven (categories + sections); article shows category breadcrumb;
|
||||
client builds and server boots with no errors.
|
||||
|
||||
### Phase 2 — Authoring UX ✅
|
||||
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.
|
||||
- **Exit check**: create/edit a page with headings, a list, a link, and an inline
|
||||
image; verify it renders sanitized on the public page.
|
||||
- **Verified** (2026-06-27): `/admin/uploads` returns `{url}` and the file serves as
|
||||
an image; a page authored with H2/H3, lists, a link, and an uploaded inline image
|
||||
round-trips through the WYSIWYG and renders sanitized publicly (link `rel` forced,
|
||||
`<script>` stripped); a toolbar edit (insert divider) saved and persisted. TipTap
|
||||
is code-split into its own chunk (lazy-loaded), keeping it off the public bundle.
|
||||
|
||||
### Phase 3 — Connectivity ✅
|
||||
- Internal `[[slug]]` links + red-link detection; `wiki_links` rebuild on save;
|
||||
backlinks on the article; tags + tag/category filtering.
|
||||
- **Exit check**: link page A→B, confirm B shows A under "Linked from"; tag filter works.
|
||||
- **Verified** (2026-06-27): internal links authored via an in-editor page picker
|
||||
(links to `/wiki/<slug>`); A→B made B list A under "Linked from"; a link to a
|
||||
non-existent page renders as a red link; removing the link on save cleared the
|
||||
backlink (link index rebuilt). Tags upsert on save, filter via `?tag=` (chips +
|
||||
flat index view), list with published counts, and orphan tags are auto-pruned.
|
||||
- Implementation note: links are plain anchors to `/wiki/<slug>` (the WYSIWYG fits
|
||||
this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`.
|
||||
|
||||
### Phase 4 — Discovery & trust ✅
|
||||
- FULLTEXT search (public search box + admin filter); revision history list /
|
||||
diff / restore.
|
||||
- **Exit check**: search returns expected pages; edit a page twice, diff the
|
||||
revisions, restore an older one, confirm a new revision is recorded.
|
||||
- **Verified** (2026-06-27): `?q=` natural-language search matches on both body
|
||||
(`recipes`→crafting) and title (`monsters`); the public search box and admin
|
||||
filter both work. A page edited twice produced 3 revisions; the History modal
|
||||
shows a word-level diff (added vs removed) of an old revision against current;
|
||||
restoring reverted the page and appended a "Restored from revision #N" entry.
|
||||
|
||||
### Verification (every phase)
|
||||
Use the preview workflow, not manual hand-off: start the dev server, exercise the
|
||||
public wiki and the admin editor, check console/network for errors, and capture a
|
||||
screenshot of the changed surface. Confirm `npm run` lint/build passes for the client
|
||||
and the server boots cleanly with `ensureSchema()` applying the migration.
|
||||
|
||||
---
|
||||
|
||||
## 9. File-change map (reference)
|
||||
|
||||
| Area | Files |
|
||||
|---|---|
|
||||
| Schema/seed | `server/db/schema.sql`, `server/db/seed.js`, `server/src/utils/db.js` (ensureSchema) |
|
||||
| Models | `server/src/model/wiki/wiki.db.js`, `wiki.model.js`, **new** `wiki.links.js` |
|
||||
| API | `server/src/router/v1/public/public.{routes,controller}.js`, `server/src/router/v1/admin/admin.{routes,controller}.js` |
|
||||
| Sanitize | **new** `server/src/utils/sanitizeHtml.js` |
|
||||
| Client API | `client/src/api/client.js` |
|
||||
| Public UI | `client/src/routes/wiki/Wiki.jsx`, `WikiArticle.jsx` |
|
||||
| Admin UI | `client/src/routes/admin/views/WikiAdmin.jsx`, `WikiEditor.jsx`, **new** category manager + revisions view |
|
||||
| Deps | `client/package.json`, `server/package.json` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions / assumptions
|
||||
|
||||
1. **Slug redirects**: assumed not needed on rename (staff wiki). Revisit if pages get
|
||||
external inbound links.
|
||||
2. **Search ranking**: FULLTEXT natural-language mode assumed; can switch to BOOLEAN
|
||||
mode if operators are wanted later.
|
||||
3. **Diff granularity**: line/word diff of the HTML source is assumed sufficient for
|
||||
revision compare; a rendered visual diff is a later nice-to-have.
|
||||
4. **Editor scope**: tables and embeds beyond images are deferred unless requested.
|
||||
45
bot/.env.example
Normal file
45
bot/.env.example
Normal file
@@ -0,0 +1,45 @@
|
||||
# ─── Runic Gateway Discord bot — local dev environment ───
|
||||
# Copy to bot/.env for running `npm run dev` outside Docker.
|
||||
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||
#
|
||||
# NOTE: there is no Discord bot token here on purpose. The token is entered
|
||||
# in the admin panel (Discord Bot page), stored encrypted in the main site's
|
||||
# DB, and pushed to this process in-memory over the internal API. It is
|
||||
# never read from an env var and never written to this process's disk.
|
||||
|
||||
PORT=4100
|
||||
|
||||
# Logging — written to BOTH the console and a log file (default <bot>/logs/bot.log).
|
||||
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
|
||||
FILE_LOG_LEVEL=debug # file verbosity
|
||||
LOG_TO_FILE=true # set false for console-only
|
||||
# LOG_DIR= # defaults to bot/logs
|
||||
# LOG_FILE=bot.log
|
||||
|
||||
# Shared secret for the internal API between this bot and the main site
|
||||
# (server/). MUST be byte-for-byte identical to BOT_INTERNAL_KEY in
|
||||
# server/.env.example / the root .env.example — it is the only auth on both
|
||||
# sides' /internal/* routes, so a mismatch silently breaks every server<->bot
|
||||
# call with 401s. Generate one long random string and copy it to both places.
|
||||
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||
|
||||
# Where this bot calls back to the main site to fetch its config on boot
|
||||
# (GET .../internal/bot-config), so a restart self-reconnects without needing
|
||||
# the admin panel to push config again. This targets the site's UNPUBLISHED
|
||||
# internal port (INTERNAL_PORT, default 3001) — NOT the public 3000. See #33.
|
||||
SITE_INTERNAL_URL=http://localhost:3001/internal/bot-config
|
||||
|
||||
# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any
|
||||
# visitor's browser can fetch. Used by /wiki (search) and /announce
|
||||
# (re-post an existing news item).
|
||||
SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
|
||||
|
||||
# Database (Phase 2+) — same physical DB as the main site, but the bot only
|
||||
# ever reads/writes its OWN tables (guild_config, mod_actions, warnings, and
|
||||
# more in later phases). It never touches site tables (users, bot_config,
|
||||
# etc.) directly. Point this at the same DB the server/ uses.
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=runic_gateway
|
||||
DB_USER=runic
|
||||
DB_PASSWORD=change-me-db-password
|
||||
3
bot/.gitignore
vendored
Normal file
3
bot/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
logs/
|
||||
16
bot/Dockerfile
Normal file
16
bot/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app/bot
|
||||
|
||||
COPY bot/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY bot/ .
|
||||
|
||||
RUN mkdir -p /app/bot/logs && chown -R node:node /app/bot/logs
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4100
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
1609
bot/package-lock.json
generated
Normal file
1609
bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
bot/package.json
Normal file
24
bot/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "runic-gateway-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord bot for the Runic Gateway community server",
|
||||
"private": true,
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"keywords": ["discord", "discord.js"],
|
||||
"author": "whitlocktech",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"mariadb": "^3.3.1",
|
||||
"node-cron": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4"
|
||||
}
|
||||
}
|
||||
15
bot/src/app.js
Normal file
15
bot/src/app.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const express = require('express')
|
||||
|
||||
const internalRouter = require('./internal/internal.routes')
|
||||
|
||||
const app = express()
|
||||
|
||||
// Internal-only listener, but don't advertise the stack anyway (defense in depth).
|
||||
app.disable('x-powered-by')
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok' }))
|
||||
app.use('/internal', internalRouter)
|
||||
|
||||
module.exports = app
|
||||
74
bot/src/bootstrap.js
vendored
Normal file
74
bot/src/bootstrap.js
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
// Runs once at process start, before the internal Express server is
|
||||
// considered ready. Fetches current config from the main site (token,
|
||||
// guildId, enabled) and reconnects immediately if enabled — so a bot
|
||||
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
||||
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
||||
// extra HTTP client dependency needed for a single startup call.
|
||||
//
|
||||
// The fetch RETRIES with backoff: on `docker compose up`, the bot and the app
|
||||
// start together and the bot's `depends_on: app` only waits for the container
|
||||
// to *start*, not for the app's internal server to be listening (it still has
|
||||
// to reach the DB and boot Express). Without retries the very first fetch loses
|
||||
// that race, bootstrap gives up, and the bot sits disconnected while the DB
|
||||
// still says enabled — the exact "enabled but disconnected until I toggle it"
|
||||
// bug. Retrying until the site answers makes a cold whole-stack start heal on
|
||||
// its own.
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
const log = createLogger('bootstrap')
|
||||
|
||||
const MAX_ATTEMPTS = 30 // ~30 tries * ~2s ≈ 1 min of patience for the app to come up
|
||||
const RETRY_DELAY_MS = 2000
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Fetch config from the site, retrying while the site is unreachable or not yet
|
||||
// ready (network error or 5xx). Returns the parsed config, or null if we gave
|
||||
// up after MAX_ATTEMPTS. A 4xx (e.g. bad internal key) is a real misconfig, not
|
||||
// a transient startup race, so we don't retry those.
|
||||
async function fetchConfig(siteUrl, key) {
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||
if (res.ok) return await res.json()
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
log.error('boot-time config fetch rejected — not retrying', { status: res.status })
|
||||
return null
|
||||
}
|
||||
log.warn('boot-time config fetch not ready — retrying', { status: res.status, attempt })
|
||||
} catch (err) {
|
||||
log.warn('boot-time config fetch errored — retrying', { message: err.message, attempt })
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS)
|
||||
}
|
||||
log.error('boot-time config fetch gave up after retries — staying disconnected until the admin panel pushes config', {
|
||||
attempts: MAX_ATTEMPTS,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const siteUrl = process.env.SITE_INTERNAL_URL
|
||||
const key = process.env.BOT_INTERNAL_KEY
|
||||
if (!siteUrl || !key) {
|
||||
log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config')
|
||||
return
|
||||
}
|
||||
|
||||
const config = await fetchConfig(siteUrl, key)
|
||||
if (!config) return
|
||||
|
||||
if (config.enabled) {
|
||||
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||
try {
|
||||
await discordManager.start({ token: config.token, guildId: config.guildId })
|
||||
} catch (err) {
|
||||
log.error('boot-time reconnect failed', { message: err.message })
|
||||
}
|
||||
} else {
|
||||
log.info('boot-time config says disabled — staying disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = bootstrap
|
||||
13
bot/src/brand.js
Normal file
13
bot/src/brand.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
|
||||
// logs carry the instance identity. Kept minimal — the bot only needs the name
|
||||
// and the accent color (as an int for discord.js embeds).
|
||||
require('dotenv').config()
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
const accentInt = (() => {
|
||||
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
})()
|
||||
|
||||
module.exports = { name, accentHex, accentInt }
|
||||
41
bot/src/db.js
Normal file
41
bot/src/db.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) —
|
||||
// mirrors server/src/utils/db.js. The bot never reads/writes any table it
|
||||
// doesn't own; site-owned tables (users, bot_config, etc.) are reached only
|
||||
// through the internal API, never directly. Schema for these tables lives in
|
||||
// server/db/schema.sql (same physical database, ensured by the main server on
|
||||
// boot) — there's no separate migration tool to justify a second database for
|
||||
// a single-guild v1 bot.
|
||||
const mariadb = require('mariadb')
|
||||
|
||||
const pool = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'runic_gateway',
|
||||
connectionLimit: 5,
|
||||
insertIdAsNumber: true,
|
||||
bigIntAsNumber: true,
|
||||
decimalAsNumber: true,
|
||||
// The driver defaults to 'local' — silently serializing bound JS Date
|
||||
// params using the HOST MACHINE's local offset instead of the DB session's
|
||||
// timezone (discovered via temp_roles.expires_at coming back hours off in
|
||||
// dev, CDT vs the container's UTC). 'auto' negotiates the actual session
|
||||
// timezone so Date round-trips correctly regardless of host TZ.
|
||||
timezone: 'auto',
|
||||
})
|
||||
|
||||
async function query(sql, params) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
return await conn.query(sql, params)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function close() {
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
module.exports = { query, close }
|
||||
49
bot/src/discord/commands/announce.command.js
Normal file
49
bot/src/discord/commands/announce.command.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const siteApiClient = require('../../site/siteApiClient')
|
||||
const newsAnnounce = require('../newsAnnounce')
|
||||
|
||||
function siteOrigin() {
|
||||
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||
return new URL(base).origin
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'announce',
|
||||
description: 'Re-post or boost an existing news item.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{ name: 'post', description: 'News post id or slug', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const idOrSlug = interaction.options.getString('post', true)
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
|
||||
const result = await siteApiClient.getNewsPost(idOrSlug)
|
||||
if (result.maintenance) {
|
||||
await interaction.editReply({ content: `Can't reach the site right now: ${result.message || 'maintenance mode'}` })
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
await interaction.editReply({ content: `Couldn't find that news post ("${idOrSlug}").` })
|
||||
return
|
||||
}
|
||||
|
||||
const post = result.data
|
||||
const origin = siteOrigin()
|
||||
try {
|
||||
await newsAnnounce.postAnnounce(interaction.client, interaction.guildId, {
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${origin}/site/news`,
|
||||
// image_url is stored relative — Discord embeds require an absolute URL.
|
||||
imageUrl: post.image_url ? new URL(post.image_url, origin).toString() : null,
|
||||
})
|
||||
await interaction.editReply({ content: `Posted "${post.title}" to the news channel.` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't post: ${err.message}` })
|
||||
}
|
||||
},
|
||||
}
|
||||
30
bot/src/discord/commands/autorole.command.js
Normal file
30
bot/src/discord/commands/autorole.command.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'autorole',
|
||||
description: 'View or set the role automatically assigned to new members on join.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'role',
|
||||
description: 'Role to auto-assign on join. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Role,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const role = interaction.options.getRole('role')
|
||||
if (!role) {
|
||||
const currentId = await guildConfig.getAutoRoleId(interaction.guildId)
|
||||
const content = currentId ? `Auto-role is set to <@&${currentId}>.` : 'No auto-role is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setAutoRoleId(interaction.guildId, role.id)
|
||||
await interaction.reply({ content: `Auto-role set to ${role}. New members will get this automatically.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
34
bot/src/discord/commands/ban.command.js
Normal file
34
bot/src/discord/commands/ban.command.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'ban',
|
||||
description: 'Ban a member from the server.',
|
||||
default_member_permissions: PermissionFlagsBits.BanMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to ban', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the ban', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't ban yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (member && !member.bannable) {
|
||||
await interaction.reply({ content: "I don't have permission to ban that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await interaction.guild.members.ban(user, { reason })
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'ban', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Banned ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
73
bot/src/discord/commands/filter.command.js
Normal file
73
bot/src/discord/commands/filter.command.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const filterWords = require('../../model/filterWords')
|
||||
const filterCache = require('../../filter/filterCache')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'filter',
|
||||
description: 'Manage the banned-word filter.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add a word to the filter.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'word', description: 'Word or phrase to ban', type: ApplicationCommandOptionType.String, required: true },
|
||||
{
|
||||
name: 'severity',
|
||||
description: 'Auto-action when triggered (default: delete)',
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: false,
|
||||
choices: [
|
||||
{ name: 'Delete only', value: 'delete' },
|
||||
{ name: 'Delete + warn', value: 'warn' },
|
||||
{ name: 'Delete + mute (10m)', value: 'mute' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a word from the filter.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'word', description: 'Word or phrase to remove', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all filtered words.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'add') {
|
||||
const word = interaction.options.getString('word', true)
|
||||
const severity = interaction.options.getString('severity') || 'delete'
|
||||
await filterWords.add({ guildId: interaction.guildId, word, severity, addedBy: interaction.user.id, addedByTag: interaction.user.tag })
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `Added "${word}" to the filter (${severity}).`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const word = interaction.options.getString('word', true)
|
||||
const removed = await filterWords.remove(interaction.guildId, word)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: removed ? `Removed "${word}" from the filter.` : `"${word}" wasn't in the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const words = await filterWords.list(interaction.guildId)
|
||||
const content = words.length === 0 ? 'The filter list is empty.' : words.map((w) => `${w.word} (${w.severity})`).join('\n')
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
61
bot/src/discord/commands/filterallow.command.js
Normal file
61
bot/src/discord/commands/filterallow.command.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const filterAllowlist = require('../../model/filterAllowlist')
|
||||
const filterCache = require('../../filter/filterCache')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'filterallow',
|
||||
description: 'Manage roles/channels that bypass the filter entirely.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'role',
|
||||
description: 'Toggle a role in/out of the filter bypass list.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'role', description: 'Role to toggle', type: ApplicationCommandOptionType.Role, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Toggle a channel in/out of the filter bypass list.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'channel', description: 'Channel to toggle', type: ApplicationCommandOptionType.Channel, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'Show current filter bypass roles/channels.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'role') {
|
||||
const role = interaction.options.getRole('role', true)
|
||||
const nowAllowed = await filterAllowlist.toggleRole(interaction.guildId, role.id)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `${role} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'channel') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const nowAllowed = await filterAllowlist.toggleChannel(interaction.guildId, channel.id)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `${channel} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const [roles, channels] = await Promise.all([
|
||||
filterAllowlist.getRoles(interaction.guildId),
|
||||
filterAllowlist.getChannels(interaction.guildId),
|
||||
])
|
||||
const roleText = roles.length ? roles.map((id) => `<@&${id}>`).join(', ') : 'none'
|
||||
const channelText = channels.length ? channels.map((id) => `<#${id}>`).join(', ') : 'none'
|
||||
await interaction.reply({ content: `Bypass roles: ${roleText}\nBypass channels: ${channelText}`, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
31
bot/src/discord/commands/index.js
Normal file
31
bot/src/discord/commands/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Command registry. Each module exports { data, execute } — `data` is the
|
||||
// slash-command definition pushed to Discord (registerCommands), `execute` is
|
||||
// the interactionCreate handler (dispatch). Adding a new command is just
|
||||
// adding a file here — discordManager.js never needs to change.
|
||||
const commands = [
|
||||
require('./ping.command'),
|
||||
require('./modlog.command'),
|
||||
require('./ban.command'),
|
||||
require('./kick.command'),
|
||||
require('./mute.command'),
|
||||
require('./warn.command'),
|
||||
require('./warnings.command'),
|
||||
require('./filter.command'),
|
||||
require('./filterallow.command'),
|
||||
require('./schedule.command'),
|
||||
require('./rolemenu.command'),
|
||||
require('./autorole.command'),
|
||||
require('./role.command'),
|
||||
require('./roles.command'),
|
||||
require('./invite.command'),
|
||||
require('./news.command'),
|
||||
require('./announce.command'),
|
||||
require('./wiki.command'),
|
||||
]
|
||||
|
||||
const byName = new Map(commands.map((c) => [c.data.name, c]))
|
||||
|
||||
module.exports = {
|
||||
all: commands,
|
||||
get: (name) => byName.get(name),
|
||||
}
|
||||
87
bot/src/discord/commands/invite.command.js
Normal file
87
bot/src/discord/commands/invite.command.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
const inviteLog = require('../../model/inviteLog')
|
||||
const inviteRotator = require('../../invites/inviteRotator')
|
||||
|
||||
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
|
||||
async function handleChannel(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
|
||||
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleRotate(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
try {
|
||||
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
|
||||
triggeredBy: interaction.user.id,
|
||||
triggeredByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLog(interaction) {
|
||||
const rows = await inviteLog.list(interaction.guildId, 10)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const who = r.triggered_by_tag || 'automatic (scheduled)'
|
||||
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
|
||||
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'invite',
|
||||
description: 'Manage the auto-rotating primary server invite.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'View or set the channel new invites are created in.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel to create invites in. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'rotate',
|
||||
description: 'Revoke the current invite and generate a new one now.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
name: 'log',
|
||||
description: 'Show recent invite rotation history.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
if (sub === 'channel') return handleChannel(interaction)
|
||||
if (sub === 'rotate') return handleRotate(interaction)
|
||||
if (sub === 'log') return handleLog(interaction)
|
||||
},
|
||||
}
|
||||
38
bot/src/discord/commands/kick.command.js
Normal file
38
bot/src/discord/commands/kick.command.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'kick',
|
||||
description: 'Kick a member from the server.',
|
||||
default_member_permissions: PermissionFlagsBits.KickMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to kick', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the kick', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't kick yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
if (!member.kickable) {
|
||||
await interaction.reply({ content: "I don't have permission to kick that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await member.kick(reason)
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'kick', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Kicked ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
33
bot/src/discord/commands/modlog.command.js
Normal file
33
bot/src/discord/commands/modlog.command.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'modlog',
|
||||
description: 'View or set the mod-log channel (ban/kick/mute/warn actions post here).',
|
||||
// Configuration, not a moderation action — gated to Manage Server rather
|
||||
// than the ModerateMembers bit the action commands use.
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel to post mod-log entries to. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getModLogChannelId(interaction.guildId)
|
||||
const content = currentId ? `Mod-log channel is set to <#${currentId}>.` : 'No mod-log channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setModLogChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Mod-log channel set to ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
49
bot/src/discord/commands/mute.command.js
Normal file
49
bot/src/discord/commands/mute.command.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
const { parseDuration, MAX_TIMEOUT_MS } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'mute',
|
||||
description: 'Timeout a member for a duration (e.g. 10m, 2h, 1d).',
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to mute', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'duration', description: 'e.g. 30s, 10m, 2h, 1d (max 28d)', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'reason', description: 'Reason for the mute', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const durationInput = interaction.options.getString('duration', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't mute yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const ms = parseDuration(durationInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid duration — use a number plus s/m/h/d, e.g. `10m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const clampedMs = Math.min(ms, MAX_TIMEOUT_MS)
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
if (!member.moderatable) {
|
||||
await interaction.reply({ content: "I don't have permission to timeout that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await member.timeout(clampedMs, reason)
|
||||
const durationSeconds = Math.round(clampedMs / 1000)
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'mute', target: user, staffUser: interaction.user, reason, durationSeconds })
|
||||
await interaction.reply({ content: `Muted ${user.tag} for ${durationInput}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
31
bot/src/discord/commands/news.command.js
Normal file
31
bot/src/discord/commands/news.command.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'news',
|
||||
description: 'View or set the channel news posts are announced to.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel for news announcements. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getNewsChannelId(interaction.guildId)
|
||||
const content = currentId ? `News channel is set to <#${currentId}>.` : 'No news channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setNewsChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `News channel set to ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
15
bot/src/discord/commands/ping.command.js
Normal file
15
bot/src/discord/commands/ping.command.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { PermissionFlagsBits } = require('discord.js')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'ping',
|
||||
description: 'Health-check — replies pong if the bot is alive and staff-permitted.',
|
||||
// Restricted by default to members with Moderate Members — proves slash
|
||||
// commands can be permission-gated via Discord's own permission model,
|
||||
// per the spec's "restrict staff commands via Discord's permission system".
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
},
|
||||
async execute(interaction) {
|
||||
await interaction.reply({ content: 'pong', ephemeral: true })
|
||||
},
|
||||
}
|
||||
71
bot/src/discord/commands/role.command.js
Normal file
71
bot/src/discord/commands/role.command.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const tempRoles = require('../../model/tempRoles')
|
||||
const { parseDuration } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'role',
|
||||
description: 'Assign or remove a role for a single member.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add a role to a member, optionally temporary.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'role', description: 'Role to add', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'duration', description: 'Optional — makes this temporary, e.g. 1h, 2d, 7d', type: ApplicationCommandOptionType.String, required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a role from a member.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'role', description: 'Role to remove', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const role = interaction.options.getRole('role', true)
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
await member.roles.add(role.id)
|
||||
const durationInput = interaction.options.getString('duration')
|
||||
if (!durationInput) {
|
||||
await interaction.reply({ content: `Added ${role} to ${user.tag}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const ms = parseDuration(durationInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({
|
||||
content: `Added ${role}, but "${durationInput}" isn't a valid duration so it won't expire automatically. Use e.g. 1h, 2d, 7d.`,
|
||||
ephemeral: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
const expiresAt = new Date(Date.now() + ms)
|
||||
await tempRoles.add({ guildId: interaction.guildId, userId: user.id, roleId: role.id, expiresAt, createdBy: interaction.user.id })
|
||||
await interaction.reply({ content: `Added ${role} to ${user.tag} until ${expiresAt.toLocaleString()}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
await member.roles.remove(role.id)
|
||||
await tempRoles.remove(interaction.guildId, user.id, role.id)
|
||||
await interaction.reply({ content: `Removed ${role} from ${user.tag}.`, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
86
bot/src/discord/commands/rolemenu.command.js
Normal file
86
bot/src/discord/commands/rolemenu.command.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const {
|
||||
PermissionFlagsBits,
|
||||
ApplicationCommandOptionType,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
} = require('discord.js')
|
||||
|
||||
const roleMenus = require('../../model/roleMenus')
|
||||
const brand = require('../../brand')
|
||||
|
||||
// Capped at 5 roles per menu — a single Discord action row holds at most 5
|
||||
// buttons, and one row keeps this a single simple slash command instead of
|
||||
// needing a multi-step builder/modal flow.
|
||||
const MAX_ROLES = 5
|
||||
|
||||
// role1/label1 are declared inline in `data` (ahead of the optional
|
||||
// `description` option, per Discord's required-before-optional rule) — this
|
||||
// generates the rest, all optional.
|
||||
function roleOptions(from, to) {
|
||||
const opts = []
|
||||
for (let i = from; i <= to; i++) {
|
||||
opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false })
|
||||
opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false })
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'rolemenu',
|
||||
description: 'Post a button menu for self-assignable roles (up to 5).',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
// Discord requires all required options before any optional ones across
|
||||
// the whole array — role1 (required) must come before description
|
||||
// (optional), even though they read more naturally in the other order.
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false },
|
||||
{ name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false },
|
||||
...roleOptions(2, MAX_ROLES),
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const title = interaction.options.getString('title', true)
|
||||
const description = interaction.options.getString('description') || undefined
|
||||
|
||||
const entries = []
|
||||
for (let i = 1; i <= MAX_ROLES; i++) {
|
||||
const role = interaction.options.getRole(`role${i}`)
|
||||
if (!role) continue
|
||||
const label = interaction.options.getString(`label${i}`) || role.name
|
||||
entries.push({ roleId: role.id, label })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder().setTitle(title).setColor(brand.accentInt)
|
||||
if (description) embed.setDescription(description)
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
entries.map((e) =>
|
||||
new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary),
|
||||
),
|
||||
)
|
||||
|
||||
const message = await channel.send({ embeds: [embed], components: [row] })
|
||||
await roleMenus.add({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
messageId: message.id,
|
||||
mapping: entries,
|
||||
createdBy: interaction.user.id,
|
||||
})
|
||||
|
||||
await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
68
bot/src/discord/commands/roles.command.js
Normal file
68
bot/src/discord/commands/roles.command.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
// Bulk targeting is "by existing role" only — the spec also mentions an
|
||||
// explicit list of members, but Discord slash commands have no multi-user
|
||||
// picker, so that variant is deferred rather than faked with a handful of
|
||||
// user1..user5 options that would feel arbitrary and cramped.
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'roles',
|
||||
description: 'Bulk role operations across members who share an existing role.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'bulk-assign',
|
||||
description: 'Add a role to every member who has another role.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'add-role', description: 'Role to add to those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'bulk-remove',
|
||||
description: 'Remove a role from every member who has another role.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'remove-role', description: 'Role to remove from those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
// Fetching every member + looping role updates can easily exceed
|
||||
// Discord's 3-second initial-response window.
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
|
||||
const hasRole = interaction.options.getRole('has-role', true)
|
||||
const members = await interaction.guild.members.fetch()
|
||||
const targets = members.filter((m) => m.roles.cache.has(hasRole.id))
|
||||
|
||||
if (sub === 'bulk-assign') {
|
||||
const addRole = interaction.options.getRole('add-role', true)
|
||||
let count = 0
|
||||
for (const member of targets.values()) {
|
||||
if (!member.roles.cache.has(addRole.id)) {
|
||||
await member.roles.add(addRole.id).catch(() => {})
|
||||
count++
|
||||
}
|
||||
}
|
||||
await interaction.editReply({ content: `Added ${addRole} to ${count} member(s) who have ${hasRole}.` })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'bulk-remove') {
|
||||
const removeRole = interaction.options.getRole('remove-role', true)
|
||||
let count = 0
|
||||
for (const member of targets.values()) {
|
||||
if (member.roles.cache.has(removeRole.id)) {
|
||||
await member.roles.remove(removeRole.id).catch(() => {})
|
||||
count++
|
||||
}
|
||||
}
|
||||
await interaction.editReply({ content: `Removed ${removeRole} from ${count} member(s) who have ${hasRole}.` })
|
||||
}
|
||||
},
|
||||
}
|
||||
121
bot/src/discord/commands/schedule.command.js
Normal file
121
bot/src/discord/commands/schedule.command.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
const cron = require('node-cron')
|
||||
|
||||
const scheduledMessages = require('../../model/scheduledMessages')
|
||||
const scheduler = require('../../scheduler/scheduler')
|
||||
const { parseDuration } = require('../../utils/duration')
|
||||
|
||||
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
|
||||
async function handleRecurring(interaction) {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const cronExpr = interaction.options.getString('cron', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
if (!cron.validate(cronExpr)) {
|
||||
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const id = await scheduledMessages.addRecurring({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
cronExpression: cronExpr,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleOnce(interaction) {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const inInput = interaction.options.getString('in', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
const ms = parseDuration(inInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const runAt = new Date(Date.now() + ms)
|
||||
const id = await scheduledMessages.addOnce({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
runAt,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleRemove(interaction) {
|
||||
const id = interaction.options.getInteger('id', true)
|
||||
const removed = await scheduledMessages.remove(interaction.guildId, id)
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleList(interaction) {
|
||||
const rows = await scheduledMessages.list(interaction.guildId)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
let kind
|
||||
if (r.cron_expression) kind = `cron \`${r.cron_expression}\``
|
||||
else if (r.sent_at) kind = `sent ${new Date(r.sent_at).toLocaleString()}`
|
||||
else kind = `due ${new Date(r.run_at).toLocaleString()}`
|
||||
const suffix = r.enabled ? '' : ' (disabled)'
|
||||
return `**#${r.id}** <#${r.channel_id}> — ${kind}${suffix}`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'schedule',
|
||||
description: 'Manage recurring and one-off scheduled channel messages.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'recurring',
|
||||
description: 'Schedule a recurring message on a cron schedule.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'once',
|
||||
description: 'Schedule a one-off message for a future time.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a scheduled message by id.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all scheduled messages.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
if (sub === 'recurring') return handleRecurring(interaction)
|
||||
if (sub === 'once') return handleOnce(interaction)
|
||||
if (sub === 'remove') return handleRemove(interaction)
|
||||
if (sub === 'list') return handleList(interaction)
|
||||
},
|
||||
}
|
||||
40
bot/src/discord/commands/warn.command.js
Normal file
40
bot/src/discord/commands/warn.command.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
const warnings = require('../../model/warnings')
|
||||
|
||||
// Escalation (e.g. "3 active warns -> auto-mute for X hours") and warning
|
||||
// decay/expiry are in the original spec but deferred past this phase — this
|
||||
// just records the warning and posts it to the mod-log, matching the
|
||||
// "Suggested Build Order" step 2 scope (core moderation).
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'warn',
|
||||
description: 'Log a warning against a member.',
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to warn', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the warning', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't warn yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await warnings.add({
|
||||
guildId: interaction.guildId,
|
||||
targetUserId: user.id,
|
||||
targetTag: user.tag,
|
||||
staffUserId: interaction.user.id,
|
||||
staffTag: interaction.user.tag,
|
||||
reason,
|
||||
})
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'warn', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Warned ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
34
bot/src/discord/commands/warnings.command.js
Normal file
34
bot/src/discord/commands/warnings.command.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, EmbedBuilder } = require('discord.js')
|
||||
|
||||
const warnings = require('../../model/warnings')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'warnings',
|
||||
description: "List a member's active warnings.",
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to look up', type: ApplicationCommandOptionType.User, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const rows = await warnings.listActive(interaction.guildId, user.id)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: `${user.tag} has no active warnings.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0xe0b070)
|
||||
.setTitle(`Warnings — ${user.tag}`)
|
||||
.setDescription(
|
||||
rows
|
||||
.map((w, i) => `**${i + 1}.** ${w.reason || '(no reason given)'} — by ${w.staff_tag || 'unknown'} on ${new Date(w.created_at).toLocaleDateString()}`)
|
||||
.join('\n'),
|
||||
)
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true })
|
||||
},
|
||||
}
|
||||
40
bot/src/discord/commands/wiki.command.js
Normal file
40
bot/src/discord/commands/wiki.command.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const siteApiClient = require('../../site/siteApiClient')
|
||||
|
||||
// Public command — no default_member_permissions restriction. Read-only:
|
||||
// searches wiki titles/content and links to the best match. Never posts to or
|
||||
// edits the wiki. Category-scoped search (spec's optional "/wiki spells
|
||||
// fireball") is deferred — the site's public search endpoint currently
|
||||
// ignores category filters whenever a text query is given.
|
||||
function siteOrigin() {
|
||||
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||
return new URL(base).origin
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'wiki',
|
||||
description: 'Search the wiki.',
|
||||
options: [{ name: 'query', description: 'What to search for', type: ApplicationCommandOptionType.String, required: true }],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const query = interaction.options.getString('query', true)
|
||||
await interaction.deferReply()
|
||||
|
||||
const result = await siteApiClient.searchWiki(query)
|
||||
if (result.maintenance) {
|
||||
await interaction.editReply({ content: `The wiki is unavailable right now: ${result.message || 'maintenance mode'}` })
|
||||
return
|
||||
}
|
||||
if (!result.ok || !result.data || result.data.length === 0) {
|
||||
await interaction.editReply({ content: `No wiki results for "${query}".` })
|
||||
return
|
||||
}
|
||||
|
||||
const best = result.data[0]
|
||||
const url = `${siteOrigin()}/wiki/${best.slug}`
|
||||
const content = best.excerpt ? `**${best.title}**\n${best.excerpt}\n${url}` : `**${best.title}**\n${url}`
|
||||
await interaction.editReply({ content })
|
||||
},
|
||||
}
|
||||
149
bot/src/discord/discordManager.js
Normal file
149
bot/src/discord/discordManager.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// Owns the single discord.js Client instance for this process: lifecycle
|
||||
// (start/stop/status) and slash-command registration/dispatch. Command
|
||||
// definitions themselves live in ./commands — this file only wires them up.
|
||||
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
|
||||
|
||||
const createLogger = require('../utils/logger')
|
||||
const commands = require('./commands')
|
||||
const messageFilter = require('./messageFilter')
|
||||
const scheduler = require('../scheduler/scheduler')
|
||||
const roleMenuHandler = require('./roleMenuHandler')
|
||||
const { handleGuildMemberAdd } = require('./guildMemberAdd')
|
||||
const { handleGuildMemberRemove } = require('./guildMemberRemove')
|
||||
const inviteTracker = require('./inviteTracker')
|
||||
const tempRoleSweeper = require('../roles/tempRoleSweeper')
|
||||
const inviteScheduler = require('../invites/inviteScheduler')
|
||||
|
||||
const log = createLogger('discord')
|
||||
|
||||
let client = null
|
||||
let guildId = null
|
||||
let status = 'disconnected' // disconnected | connecting | connected | error
|
||||
let statusDetail = null
|
||||
let lastConnectedAt = null
|
||||
|
||||
async function registerCommands(applicationId, targetGuildId) {
|
||||
const rest = new REST({ version: '10' }).setToken(client.token)
|
||||
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
|
||||
body: commands.all.map((c) => c.data),
|
||||
})
|
||||
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!client) {
|
||||
status = 'disconnected'
|
||||
statusDetail = null
|
||||
return
|
||||
}
|
||||
scheduler.stop()
|
||||
tempRoleSweeper.stop()
|
||||
inviteScheduler.stop()
|
||||
try {
|
||||
await client.destroy()
|
||||
} catch (err) {
|
||||
log.warn('error while destroying client', { message: err.message })
|
||||
}
|
||||
client = null
|
||||
status = 'disconnected'
|
||||
statusDetail = null
|
||||
log.info('discord client disconnected')
|
||||
}
|
||||
|
||||
// Post-login startup: register commands and start the background workers. A
|
||||
// failure here leaves the client connected but flags an error status.
|
||||
async function onReady() {
|
||||
try {
|
||||
await registerCommands(client.application.id, guildId)
|
||||
await scheduler.start(client)
|
||||
tempRoleSweeper.start(client)
|
||||
inviteScheduler.start(client, guildId)
|
||||
await inviteTracker.prime(client, guildId)
|
||||
status = 'connected'
|
||||
statusDetail = null
|
||||
lastConnectedAt = new Date()
|
||||
log.info('discord client ready', { user: client.user?.tag, guildId })
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = `startup failed: ${err.message}`
|
||||
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Route an interaction: role-menu handler first, then chat-input slash commands.
|
||||
async function onInteractionCreate(interaction) {
|
||||
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const command = commands.get(interaction.commandName)
|
||||
if (!command) return
|
||||
try {
|
||||
await command.execute(interaction)
|
||||
} catch (err) {
|
||||
log.error('command execution failed', { command: interaction.commandName, message: err.message })
|
||||
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
|
||||
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
|
||||
else await interaction.reply(payload)
|
||||
}
|
||||
}
|
||||
|
||||
// start({ token, guildId }) — (re)connects. Always stops any existing client
|
||||
// first so re-saving config or toggling Enabled off/on is idempotent.
|
||||
async function start({ token, guildId: gid }) {
|
||||
await stop()
|
||||
guildId = gid
|
||||
status = 'connecting'
|
||||
statusDetail = null
|
||||
|
||||
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
|
||||
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
|
||||
// in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
|
||||
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
|
||||
client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildInvites,
|
||||
],
|
||||
})
|
||||
|
||||
client.once('ready', onReady)
|
||||
client.on('interactionCreate', onInteractionCreate)
|
||||
client.on('messageCreate', messageFilter.handleMessageCreate)
|
||||
client.on('guildMemberAdd', handleGuildMemberAdd)
|
||||
client.on('guildMemberRemove', handleGuildMemberRemove)
|
||||
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
|
||||
client.on('inviteCreate', inviteTracker.onInviteCreate)
|
||||
client.on('inviteDelete', inviteTracker.onInviteDelete)
|
||||
|
||||
client.on('error', (err) => {
|
||||
status = 'error'
|
||||
statusDetail = err.message
|
||||
log.error('discord client error', { message: err.message })
|
||||
})
|
||||
|
||||
try {
|
||||
await client.login(token)
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = err.message
|
||||
client = null
|
||||
log.error('discord login failed', { message: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return { status, statusDetail, guildId, lastConnectedAt }
|
||||
}
|
||||
|
||||
// For code that needs the live client + which guild it's connected to (the
|
||||
// /internal/announce handler, slash commands already get both from the
|
||||
// interaction itself so they don't need this). Returns null if disconnected.
|
||||
function getConnection() {
|
||||
if (!client || status !== 'connected') return null
|
||||
return { client, guildId }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getStatus, getConnection }
|
||||
45
bot/src/discord/guildMemberAdd.js
Normal file
45
bot/src/discord/guildMemberAdd.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Member join handling: record the join event (with best-effort invite
|
||||
// attribution, Phase 6b) then apply the configured auto-role. Requires the
|
||||
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
|
||||
// and, for invite attribution, the GuildInvites intent.
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const memberEvents = require('../model/memberEvents')
|
||||
const inviteTracker = require('./inviteTracker')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('members')
|
||||
|
||||
async function handleGuildMemberAdd(member) {
|
||||
// Attribute the invite first (diffs the invite-use cache), then record the join.
|
||||
// Both are best-effort — a failure here must never block the auto-role below.
|
||||
let invite = { code: null, inviterId: null, inviterTag: null }
|
||||
try {
|
||||
invite = await inviteTracker.attribute(member)
|
||||
} catch (err) {
|
||||
log.warn('invite attribution threw', { userId: member.id, message: err.message })
|
||||
}
|
||||
try {
|
||||
await memberEvents.record({
|
||||
guildId: member.guild.id,
|
||||
eventType: 'join',
|
||||
discordUserId: member.id,
|
||||
username: member.user?.tag,
|
||||
inviteCode: invite.code,
|
||||
inviterId: invite.inviterId,
|
||||
inviterTag: invite.inviterTag,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('member join record failed', { userId: member.id, message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
|
||||
if (!roleId) return
|
||||
await member.roles.add(roleId)
|
||||
log.info('auto-role assigned', { userId: member.id, roleId })
|
||||
} catch (err) {
|
||||
log.warn('auto-role assignment failed', { userId: member.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleGuildMemberAdd }
|
||||
23
bot/src/discord/guildMemberRemove.js
Normal file
23
bot/src/discord/guildMemberRemove.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Member leave handling (Phase 6b): record a leave event for the dashboard's
|
||||
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
|
||||
// distinguish them on this event, and the mod-action (if any) is logged
|
||||
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
|
||||
const memberEvents = require('../model/memberEvents')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('members')
|
||||
|
||||
async function handleGuildMemberRemove(member) {
|
||||
try {
|
||||
await memberEvents.record({
|
||||
guildId: member.guild.id,
|
||||
eventType: 'leave',
|
||||
discordUserId: member.id,
|
||||
username: member.user?.tag,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('member leave record failed', { userId: member.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleGuildMemberRemove }
|
||||
74
bot/src/discord/inviteTracker.js
Normal file
74
bot/src/discord/inviteTracker.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
|
||||
// which invite a member used, so the standard approach is to keep a cache of
|
||||
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
|
||||
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
|
||||
// already creates/deletes invites, so it has the permission). All calls are
|
||||
// best-effort: any failure just yields a null attribution and the join is still
|
||||
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
// guildId -> Map<inviteCode, uses>
|
||||
const cache = new Map()
|
||||
|
||||
async function snapshot(guild) {
|
||||
const map = new Map()
|
||||
const invites = await guild.invites.fetch()
|
||||
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
|
||||
return map
|
||||
}
|
||||
|
||||
// Populate the cache for a guild (call once the client is ready).
|
||||
async function prime(client, guildId) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
|
||||
cache.set(guildId, await snapshot(guild))
|
||||
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
|
||||
} catch (err) {
|
||||
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function onInviteCreate(invite) {
|
||||
if (!invite.guild) return
|
||||
const g = cache.get(invite.guild.id) || new Map()
|
||||
g.set(invite.code, invite.uses || 0)
|
||||
cache.set(invite.guild.id, g)
|
||||
}
|
||||
|
||||
function onInviteDelete(invite) {
|
||||
if (!invite.guild) return
|
||||
const g = cache.get(invite.guild.id)
|
||||
if (g) g.delete(invite.code)
|
||||
}
|
||||
|
||||
// Diff current invite uses against the cached snapshot to find which invite the
|
||||
// joining member used, then refresh the cache. Returns { code, inviterId,
|
||||
// inviterTag } with nulls when it can't be determined.
|
||||
async function attribute(member) {
|
||||
const empty = { code: null, inviterId: null, inviterTag: null }
|
||||
try {
|
||||
const guild = member.guild
|
||||
const before = cache.get(guild.id) || new Map()
|
||||
const current = await guild.invites.fetch()
|
||||
|
||||
let found = empty
|
||||
for (const inv of current.values()) {
|
||||
const prev = before.get(inv.code) || 0
|
||||
if ((inv.uses || 0) > prev && found === empty) {
|
||||
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
|
||||
}
|
||||
}
|
||||
|
||||
const next = new Map()
|
||||
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
|
||||
cache.set(guild.id, next)
|
||||
return found
|
||||
} catch (err) {
|
||||
log.warn('invite attribution failed', { message: err.message })
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }
|
||||
136
bot/src/discord/messageFilter.js
Normal file
136
bot/src/discord/messageFilter.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// messageCreate orchestration: allowlist bypass -> invite link -> banned word
|
||||
// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn
|
||||
// (no severity tiers for those, unlike the word filter) — kept simple per the
|
||||
// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute
|
||||
// duration; per-severity-configurable durations are a future refinement.
|
||||
const filterCache = require('../filter/filterCache')
|
||||
const { findMatch } = require('../filter/normalize')
|
||||
const inviteFilter = require('../filter/inviteFilter')
|
||||
const spamFilter = require('../filter/spamFilter')
|
||||
const warnings = require('../model/warnings')
|
||||
const filterHits = require('../model/filterHits')
|
||||
const spamHits = require('../model/spamHits')
|
||||
const modLog = require('./modLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('filter')
|
||||
|
||||
const FILTER_MUTE_SECONDS = 600 // 10 minutes
|
||||
|
||||
function botActor(client) {
|
||||
return { id: client.user.id, tag: client.user.tag }
|
||||
}
|
||||
|
||||
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
|
||||
// break the moderation action it accompanies, so failures are swallowed+logged.
|
||||
async function recordFilterHit(message, hitType, matched, actionTaken) {
|
||||
try {
|
||||
await filterHits.record({
|
||||
guildId: message.guildId,
|
||||
hitType,
|
||||
discordUserId: message.author.id,
|
||||
username: message.author.tag,
|
||||
channelId: message.channelId,
|
||||
matched,
|
||||
actionTaken,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('filter hit record failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function recordSpamHit(message, spamType) {
|
||||
try {
|
||||
await spamHits.record({
|
||||
guildId: message.guildId,
|
||||
spamType,
|
||||
discordUserId: message.author.id,
|
||||
username: message.author.tag,
|
||||
channelId: message.channelId,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('spam hit record failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
|
||||
// effect (records this message's timestamp) so it must be evaluated first, and
|
||||
// exactly once — mirroring the original OR-order.
|
||||
function detectSpam(message) {
|
||||
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
|
||||
if (spamFilter.isMassMention(message)) return 'mass_mention'
|
||||
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
|
||||
return null
|
||||
}
|
||||
|
||||
async function isBypassed(message, cache) {
|
||||
if (cache.allowChannels.has(message.channelId)) return true
|
||||
const memberRoles = message.member ? message.member.roles.cache : null
|
||||
return Boolean(memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id)))
|
||||
}
|
||||
|
||||
async function applyWarnAction(message, reason) {
|
||||
const staff = botActor(message.client)
|
||||
await warnings.add({
|
||||
guildId: message.guildId,
|
||||
targetUserId: message.author.id,
|
||||
targetTag: message.author.tag,
|
||||
staffUserId: staff.id,
|
||||
staffTag: staff.tag,
|
||||
reason,
|
||||
})
|
||||
await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason })
|
||||
}
|
||||
|
||||
async function applyMuteAction(message, reason) {
|
||||
const staff = botActor(message.client)
|
||||
if (message.member && message.member.moderatable) {
|
||||
await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason)
|
||||
}
|
||||
await modLog.record({
|
||||
client: message.client,
|
||||
guildId: message.guildId,
|
||||
actionType: 'mute',
|
||||
target: message.author,
|
||||
staffUser: staff,
|
||||
reason,
|
||||
durationSeconds: FILTER_MUTE_SECONDS,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMessageCreate(message) {
|
||||
if (message.author.bot || !message.guildId) return
|
||||
|
||||
try {
|
||||
const cache = await filterCache.getOrLoad(message.guildId)
|
||||
if (await isBypassed(message, cache)) return
|
||||
|
||||
const foreignCode = await inviteFilter.foreignInviteCode(message)
|
||||
if (foreignCode) {
|
||||
await message.delete().catch(() => {})
|
||||
await recordFilterHit(message, 'invite', foreignCode, 'warn')
|
||||
await applyWarnAction(message, 'Posted a Discord invite link')
|
||||
return
|
||||
}
|
||||
|
||||
const match = findMatch(message.content, cache.words)
|
||||
if (match) {
|
||||
await message.delete().catch(() => {})
|
||||
await recordFilterHit(message, 'word', match.word, match.severity)
|
||||
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
|
||||
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
|
||||
return
|
||||
}
|
||||
|
||||
const spamType = detectSpam(message)
|
||||
if (spamType) {
|
||||
await message.delete().catch(() => {})
|
||||
await recordSpamHit(message, spamType)
|
||||
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('messageFilter failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleMessageCreate }
|
||||
81
bot/src/discord/modLog.js
Normal file
81
bot/src/discord/modLog.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Shared by every moderation command (ban/kick/mute/warn): writes the audit
|
||||
// row and posts the embed to the configured mod-log channel. Takes `client`
|
||||
// as a parameter (from interaction.client) rather than importing
|
||||
// discordManager directly, to avoid a require cycle (discordManager -> commands
|
||||
// -> modLog -> discordManager).
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const db = require('../db')
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('modlog')
|
||||
|
||||
const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 }
|
||||
|
||||
async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) {
|
||||
await db.query(
|
||||
`INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null],
|
||||
)
|
||||
|
||||
try {
|
||||
const channelId = await guildConfig.getModLogChannelId(guildId)
|
||||
if (!channelId) return
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) return
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR[actionType] || 0x9aa5b1)
|
||||
.setTitle(actionType.toUpperCase())
|
||||
.addFields(
|
||||
{ name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true },
|
||||
{ name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true },
|
||||
)
|
||||
.setTimestamp()
|
||||
if (reason) embed.addFields({ name: 'Reason', value: reason })
|
||||
if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true })
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
} catch (err) {
|
||||
log.warn('failed to post mod-log embed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Post an "appeal approved → action reversed" embed to the mod-log channel.
|
||||
// Unlike record() this NEVER inserts a mod_actions row — the reversal is an
|
||||
// out-of-band correction driven by the site's appeals flow, not a new staff
|
||||
// action. Best-effort: a missing channel or send failure is logged, not thrown.
|
||||
async function postReversal({ client, guildId, actionType, discordUserId, appealId }) {
|
||||
try {
|
||||
const channelId = await guildConfig.getModLogChannelId(guildId)
|
||||
if (!channelId) return
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) return
|
||||
|
||||
const reversed = actionType === 'ban' ? 'Ban lifted (unbanned)' : 'Mute cleared (timeout removed)'
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x88c0a0)
|
||||
.setTitle('APPEAL APPROVED')
|
||||
.addFields(
|
||||
{ name: 'Action reversed', value: reversed, inline: true },
|
||||
{ name: 'Target id', value: `${discordUserId}`, inline: true },
|
||||
{ name: 'Appeal', value: `#${appealId}`, inline: true },
|
||||
)
|
||||
.setTimestamp()
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
} catch (err) {
|
||||
log.warn('failed to post appeal-reversal embed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
module.exports = { record, postReversal }
|
||||
27
bot/src/discord/newsAnnounce.js
Normal file
27
bot/src/discord/newsAnnounce.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Shared by the /internal/announce webhook (site publishes a news post) and
|
||||
// the manual /announce command (staff re-posts/boosts an existing one) — so
|
||||
// both paths produce an identical embed.
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const brand = require('../brand')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('news')
|
||||
|
||||
async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) {
|
||||
const channelId = await guildConfig.getNewsChannelId(guildId)
|
||||
if (!channelId) throw new Error('No news channel configured — set one with /news first.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
|
||||
|
||||
const embed = new EmbedBuilder().setColor(brand.accentInt).setTitle(title).setURL(url)
|
||||
if (excerpt) embed.setDescription(excerpt)
|
||||
if (imageUrl) embed.setImage(imageUrl)
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
log.info('news announced', { title, channelId })
|
||||
}
|
||||
|
||||
module.exports = { postAnnounce }
|
||||
41
bot/src/discord/roleMenuHandler.js
Normal file
41
bot/src/discord/roleMenuHandler.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Button-based self-assignable role menus. customId is `rolemenu:<roleId>` —
|
||||
// the message's own id (not known until after it's sent, so it can't be
|
||||
// embedded in the customId itself) is instead used to look up the tracked
|
||||
// role_menus row and confirm the clicked roleId is really part of that
|
||||
// menu's mapping, so a stale/foreign button can't toggle an untracked role.
|
||||
const roleMenus = require('../model/roleMenus')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('rolemenu')
|
||||
|
||||
const PREFIX = 'rolemenu:'
|
||||
|
||||
// Returns true if this handler owned the interaction (caller should stop
|
||||
// looking for another handler), false if it's not a role-menu button at all.
|
||||
async function handleInteraction(interaction) {
|
||||
if (!interaction.isButton() || !interaction.customId.startsWith(PREFIX)) return false
|
||||
|
||||
const roleId = interaction.customId.slice(PREFIX.length)
|
||||
try {
|
||||
const menu = await roleMenus.getByMessageId(interaction.message.id)
|
||||
if (!menu || !menu.mapping.some((m) => m.roleId === roleId)) {
|
||||
await interaction.reply({ content: 'This role menu is no longer valid.', ephemeral: true })
|
||||
return true
|
||||
}
|
||||
|
||||
const member = interaction.member
|
||||
if (member.roles.cache.has(roleId)) {
|
||||
await member.roles.remove(roleId)
|
||||
await interaction.reply({ content: `Removed <@&${roleId}>.`, ephemeral: true })
|
||||
} else {
|
||||
await member.roles.add(roleId)
|
||||
await interaction.reply({ content: `Added <@&${roleId}>.`, ephemeral: true })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('role menu toggle failed', { message: err.message })
|
||||
await interaction.reply({ content: 'Something went wrong toggling that role.', ephemeral: true }).catch(() => {})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = { handleInteraction }
|
||||
31
bot/src/filter/filterCache.js
Normal file
31
bot/src/filter/filterCache.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// In-memory per-guild filter state (word list + allowlist), loaded at startup
|
||||
// and refreshed on config change — the messageCreate handler runs on every
|
||||
// message, so it must never hit the DB per message (per the spec's
|
||||
// performance note).
|
||||
const filterWords = require('../model/filterWords')
|
||||
const filterAllowlist = require('../model/filterAllowlist')
|
||||
|
||||
const cache = new Map() // guildId -> { words, allowRoles: Set, allowChannels: Set }
|
||||
|
||||
async function load(guildId) {
|
||||
const [words, roles, channels] = await Promise.all([
|
||||
filterWords.list(guildId),
|
||||
filterAllowlist.getRoles(guildId),
|
||||
filterAllowlist.getChannels(guildId),
|
||||
])
|
||||
const entry = { words, allowRoles: new Set(roles), allowChannels: new Set(channels) }
|
||||
cache.set(guildId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
// Lazy-loads on first access per guild (e.g. the first message after boot).
|
||||
async function getOrLoad(guildId) {
|
||||
return cache.get(guildId) || load(guildId)
|
||||
}
|
||||
|
||||
// Called by /filter and /filterallow after any mutation.
|
||||
function refresh(guildId) {
|
||||
return load(guildId)
|
||||
}
|
||||
|
||||
module.exports = { getOrLoad, refresh }
|
||||
26
bot/src/filter/inviteFilter.js
Normal file
26
bot/src/filter/inviteFilter.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Detects Discord invite links and blocks any that don't resolve to the
|
||||
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
|
||||
// (expired/invalid/vanity-only) is treated as foreign too — safer default
|
||||
// than silently letting an unresolvable link through.
|
||||
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-z0-9-]+)/gi
|
||||
|
||||
// Returns the first foreign (or unresolvable) invite code found in the message,
|
||||
// or null if the message contains no foreign invites. Returning the code (rather
|
||||
// than a bare boolean) lets the caller record which invite was blocked.
|
||||
async function foreignInviteCode(message) {
|
||||
const matches = [...message.content.matchAll(INVITE_REGEX)]
|
||||
if (matches.length === 0) return null
|
||||
|
||||
for (const match of matches) {
|
||||
const code = match[1]
|
||||
try {
|
||||
const invite = await message.client.fetchInvite(code)
|
||||
if (invite.guild?.id !== message.guildId) return code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = { foreignInviteCode }
|
||||
33
bot/src/filter/normalize.js
Normal file
33
bot/src/filter/normalize.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Basic obfuscation-resistant normalization for the word filter: lowercase,
|
||||
// common leetspeak substitutions, and collapsing 3+ repeated characters
|
||||
// ("sooooo" -> "so") to one. Deliberately simple per the spec ("start simple,
|
||||
// leave room to tighten later") — spaced-out letters ("b a d") and more exotic
|
||||
// unicode lookalikes aren't handled yet.
|
||||
const SUBS = { 4: 'a', '@': 'a', 3: 'e', 1: 'i', '!': 'i', 0: 'o', $: 's', 5: 's', 7: 't' }
|
||||
const SUB_CHARS = /[4@31!05$7]/g
|
||||
|
||||
function normalize(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(SUB_CHARS, (ch) => SUBS[ch] || ch)
|
||||
.replace(/(.)\1{2,}/g, '$1')
|
||||
}
|
||||
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// Word-boundary match against already-normalized text. `word` is normalized
|
||||
// here too, so callers can pass the raw stored value.
|
||||
function matches(normalizedText, word) {
|
||||
const pattern = new RegExp(`\\b${escapeRegex(normalize(word))}\\b`, 'i')
|
||||
return pattern.test(normalizedText)
|
||||
}
|
||||
|
||||
// Returns the first matching filter_words row ({word, severity}) or null.
|
||||
function findMatch(content, words) {
|
||||
const normalizedText = normalize(content)
|
||||
return words.find((w) => matches(normalizedText, w.word)) || null
|
||||
}
|
||||
|
||||
module.exports = { normalize, matches, findMatch }
|
||||
42
bot/src/filter/spamFilter.js
Normal file
42
bot/src/filter/spamFilter.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Basic in-memory spam/rate-limit detection. Per-user message-rate tracking is
|
||||
// the only stateful piece here (mass-mention/mass-emoji are per-message
|
||||
// counts) — kept in memory rather than the DB since this runs on every
|
||||
// message and needs to be fast.
|
||||
const RATE_LIMIT_COUNT = 5
|
||||
const RATE_LIMIT_WINDOW_MS = 5000
|
||||
const MENTION_THRESHOLD = 5
|
||||
const EMOJI_THRESHOLD = 10
|
||||
const SWEEP_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
const history = new Map() // `${guildId}:${userId}` -> timestamps[]
|
||||
|
||||
function isRateLimited(guildId, userId) {
|
||||
const key = `${guildId}:${userId}`
|
||||
const now = Date.now()
|
||||
const timestamps = (history.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
|
||||
timestamps.push(now)
|
||||
history.set(key, timestamps)
|
||||
return timestamps.length > RATE_LIMIT_COUNT
|
||||
}
|
||||
|
||||
function isMassMention(message) {
|
||||
return message.mentions.users.size + message.mentions.roles.size > MENTION_THRESHOLD
|
||||
}
|
||||
|
||||
const EMOJI_REGEX = /<a?:\w+:\d+>|\p{Extended_Pictographic}/gu
|
||||
|
||||
function isMassEmoji(content) {
|
||||
const count = (content.match(EMOJI_REGEX) || []).length
|
||||
return count > EMOJI_THRESHOLD
|
||||
}
|
||||
|
||||
// Periodic cleanup so `history` doesn't grow unbounded over a long-running
|
||||
// process — drops any key with no recent activity.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, timestamps] of history) {
|
||||
if (timestamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) history.delete(key)
|
||||
}
|
||||
}, SWEEP_INTERVAL_MS).unref()
|
||||
|
||||
module.exports = { isRateLimited, isMassMention, isMassEmoji }
|
||||
102
bot/src/internal/internal.controller.js
Normal file
102
bot/src/internal/internal.controller.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const discordManager = require('../discord/discordManager')
|
||||
const newsAnnounce = require('../discord/newsAnnounce')
|
||||
const modLog = require('../discord/modLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('internal')
|
||||
|
||||
const REVERSIBLE = new Set(['ban', 'mute'])
|
||||
// discord.js REST error code for removing a ban that no longer exists.
|
||||
const UNKNOWN_BAN = 10026
|
||||
|
||||
// POST /internal/config — called by the main server right after an admin
|
||||
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
|
||||
// (via a GET to the server for the current config, then this same start/stop
|
||||
// logic locally). Body: { token, guildId, enabled }.
|
||||
async function setConfig(req, res) {
|
||||
const { token, guildId, enabled } = req.body || {}
|
||||
try {
|
||||
if (enabled) {
|
||||
if (!token || !guildId) {
|
||||
return res.status(400).json({ message: 'token and guildId are required when enabled' })
|
||||
}
|
||||
await discordManager.start({ token, guildId })
|
||||
} else {
|
||||
await discordManager.stop()
|
||||
}
|
||||
return res.json(discordManager.getStatus())
|
||||
} catch (err) {
|
||||
log.error('setConfig failed', { message: err.message })
|
||||
// Still 200 with an error status — the caller (admin panel) should surface
|
||||
// discordManager's status/statusDetail rather than treat this as a 5xx.
|
||||
return res.json(discordManager.getStatus())
|
||||
}
|
||||
}
|
||||
|
||||
// GET /internal/status — live connection state, polled by the admin panel.
|
||||
function getStatusHandler(req, res) {
|
||||
return res.json(discordManager.getStatus())
|
||||
}
|
||||
|
||||
// POST /internal/announce — called by the main server right after a news
|
||||
// post is published. Body: { title, excerpt, url, imageUrl }.
|
||||
async function announce(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
try {
|
||||
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
|
||||
return res.json({ posted: true })
|
||||
} catch (err) {
|
||||
log.warn('announce failed', { message: err.message })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /internal/mod-reverse — called by the main server when a staffer APPROVES
|
||||
// a moderation appeal (Phase 6d). Body: { discord_user_id, action_type, appeal_id }.
|
||||
// Reverses the Discord action: 'ban' → lift the ban, 'mute' → clear the timeout.
|
||||
// Idempotent-friendly: an already-lifted ban ("Unknown Ban") or a member who has
|
||||
// left the guild is treated as success (the desired end state already holds).
|
||||
async function reverseModAction(req, res) {
|
||||
const { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId } = req.body || {}
|
||||
|
||||
if (!REVERSIBLE.has(actionType)) {
|
||||
return res.status(400).json({ message: 'action_type must be ban or mute' })
|
||||
}
|
||||
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
|
||||
const reason = `Appeal #${appealId} approved`
|
||||
try {
|
||||
const guild = await connection.client.guilds.fetch(connection.guildId)
|
||||
|
||||
if (actionType === 'ban') {
|
||||
try {
|
||||
await guild.bans.remove(discordUserId, reason)
|
||||
} catch (err) {
|
||||
// Unknown Ban → already unbanned; anything else is a real failure.
|
||||
if (err.code !== UNKNOWN_BAN) throw err
|
||||
}
|
||||
} else {
|
||||
// mute: clear the timeout. If the member has left, there's nothing to clear.
|
||||
const member = await guild.members.fetch(discordUserId).catch(() => null)
|
||||
if (member) await member.timeout(null, reason)
|
||||
}
|
||||
|
||||
await modLog.postReversal({
|
||||
client: connection.client,
|
||||
guildId: connection.guildId,
|
||||
actionType,
|
||||
discordUserId,
|
||||
appealId,
|
||||
})
|
||||
|
||||
return res.json({ reversed: true })
|
||||
} catch (err) {
|
||||
log.error('mod-reverse failed', { message: err.message, actionType, discordUserId })
|
||||
return res.status(500).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }
|
||||
15
bot/src/internal/internal.routes.js
Normal file
15
bot/src/internal/internal.routes.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const express = require('express')
|
||||
|
||||
const requireInternalKey = require('./requireInternalKey')
|
||||
const ctrl = require('./internal.controller')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use(requireInternalKey)
|
||||
|
||||
router.post('/config', ctrl.setConfig)
|
||||
router.get('/status', ctrl.getStatus)
|
||||
router.post('/announce', ctrl.announce)
|
||||
router.post('/mod-reverse', ctrl.reverseModAction)
|
||||
|
||||
module.exports = router
|
||||
19
bot/src/internal/requireInternalKey.js
Normal file
19
bot/src/internal/requireInternalKey.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Gate for the bot's /internal/* API. The only caller is the main Runic Gateway
|
||||
// server, over the private compose network — never expose this route through
|
||||
// the public reverse proxy. Timing-safe compare so response time can't be used
|
||||
// to brute-force the shared secret one byte at a time.
|
||||
const crypto = require('crypto')
|
||||
|
||||
function requireInternalKey(req, res, next) {
|
||||
const expected = process.env.BOT_INTERNAL_KEY || ''
|
||||
const provided = req.get('X-Internal-Key') || ''
|
||||
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(provided)
|
||||
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
|
||||
if (!match) return res.status(401).json({ message: 'Unauthorized' })
|
||||
return next()
|
||||
}
|
||||
|
||||
module.exports = requireInternalKey
|
||||
37
bot/src/invites/inviteRotator.js
Normal file
37
bot/src/invites/inviteRotator.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Shared by both /invite rotate and the weekly cron job (inviteScheduler.js)
|
||||
// so manual and automatic rotations log identically. maxAge is set to match
|
||||
// the rotation cadence as defense-in-depth: if the scheduled rotation were
|
||||
// ever to silently stop running, the invite still expires on its own instead
|
||||
// of staying live forever.
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const inviteLog = require('../model/inviteLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
const ROTATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 // 7 days
|
||||
|
||||
async function rotate(client, guildId, { triggeredBy, triggeredByTag } = {}) {
|
||||
const channelId = await guildConfig.getInviteChannelId(guildId)
|
||||
if (!channelId) throw new Error('No invite channel configured — set one with /invite channel first.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) throw new Error('Configured invite channel is missing or not text-based.')
|
||||
|
||||
const current = await inviteLog.getCurrent(guildId)
|
||||
if (current) {
|
||||
try {
|
||||
await channel.guild.invites.delete(current.invite_code, 'Invite rotation')
|
||||
} catch (err) {
|
||||
log.warn('failed to revoke previous invite (may already be gone)', { message: err.message })
|
||||
}
|
||||
await inviteLog.markRevoked(current.id)
|
||||
}
|
||||
|
||||
const invite = await channel.createInvite({ maxAge: ROTATION_MAX_AGE_SECONDS, unique: true, reason: 'Invite rotation' })
|
||||
await inviteLog.record({ guildId, channelId, inviteCode: invite.code, triggeredBy, triggeredByTag })
|
||||
log.info('invite rotated', { code: invite.code, triggeredBy: triggeredByTag || 'automatic (scheduled)' })
|
||||
return invite
|
||||
}
|
||||
|
||||
module.exports = { rotate }
|
||||
31
bot/src/invites/inviteScheduler.js
Normal file
31
bot/src/invites/inviteScheduler.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Weekly automatic invite rotation (Sundays at midnight). A missing invite
|
||||
// channel config just skips quietly (warn-logged) — most guilds won't set
|
||||
// this up on day one, and that shouldn't spam errors every week until they do.
|
||||
const cron = require('node-cron')
|
||||
|
||||
const inviteRotator = require('./inviteRotator')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
let task = null
|
||||
|
||||
function start(client, guildId) {
|
||||
task = cron.schedule('0 0 * * 0', async () => {
|
||||
try {
|
||||
await inviteRotator.rotate(client, guildId, {})
|
||||
} catch (err) {
|
||||
log.warn('scheduled invite rotation skipped', { message: err.message })
|
||||
}
|
||||
})
|
||||
log.info('invite rotation scheduler started')
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (task) {
|
||||
task.stop()
|
||||
task = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop }
|
||||
40
bot/src/model/filterAllowlist.js
Normal file
40
bot/src/model/filterAllowlist.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Roles/channels that bypass word/invite/spam filtering entirely (staff roles,
|
||||
// bot-commands channels, etc.). Stored as CSV in guild_config rather than a
|
||||
// separate table — short, rarely-changed lists.
|
||||
const guildConfig = require('./guildConfig')
|
||||
|
||||
const ROLES_KEY = 'filter_allow_roles'
|
||||
const CHANNELS_KEY = 'filter_allow_channels'
|
||||
|
||||
function parseCsv(value) {
|
||||
return value ? value.split(',').filter(Boolean) : []
|
||||
}
|
||||
|
||||
async function getRoles(guildId) {
|
||||
return parseCsv(await guildConfig.get(guildId, ROLES_KEY))
|
||||
}
|
||||
|
||||
async function getChannels(guildId) {
|
||||
return parseCsv(await guildConfig.get(guildId, CHANNELS_KEY))
|
||||
}
|
||||
|
||||
// Toggle: adds the id if absent, removes it if present. Returns the new state (true = now allowed).
|
||||
async function toggleRole(guildId, roleId) {
|
||||
const roles = await getRoles(guildId)
|
||||
const idx = roles.indexOf(roleId)
|
||||
if (idx === -1) roles.push(roleId)
|
||||
else roles.splice(idx, 1)
|
||||
await guildConfig.set(guildId, ROLES_KEY, roles.join(','))
|
||||
return idx === -1
|
||||
}
|
||||
|
||||
async function toggleChannel(guildId, channelId) {
|
||||
const channels = await getChannels(guildId)
|
||||
const idx = channels.indexOf(channelId)
|
||||
if (idx === -1) channels.push(channelId)
|
||||
else channels.splice(idx, 1)
|
||||
await guildConfig.set(guildId, CHANNELS_KEY, channels.join(','))
|
||||
return idx === -1
|
||||
}
|
||||
|
||||
module.exports = { getRoles, getChannels, toggleRole, toggleChannel }
|
||||
15
bot/src/model/filterHits.js
Normal file
15
bot/src/model/filterHits.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
|
||||
// word filter or foreign-invite filter deletes a message. mod_actions still
|
||||
// records the resulting warn/mute separately. Schema: server/db/schema.sql
|
||||
// (filter_hits).
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
|
||||
await db.query(
|
||||
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
22
bot/src/model/filterWords.js
Normal file
22
bot/src/model/filterWords.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function add({ guildId, word, severity, addedBy, addedByTag }) {
|
||||
await db.query(
|
||||
`INSERT INTO filter_words (guild_id, word, severity, added_by, added_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE severity = VALUES(severity), added_by = VALUES(added_by), added_by_tag = VALUES(added_by_tag)`,
|
||||
[guildId, word.toLowerCase(), severity || 'delete', addedBy || null, addedByTag || null],
|
||||
)
|
||||
}
|
||||
|
||||
// Returns true if a row was actually removed.
|
||||
async function remove(guildId, word) {
|
||||
const res = await db.query('DELETE FROM filter_words WHERE guild_id = ? AND word = ?', [guildId, word.toLowerCase()])
|
||||
return Number(res.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
async function list(guildId) {
|
||||
return db.query('SELECT word, severity FROM filter_words WHERE guild_id = ? ORDER BY word ASC', [guildId])
|
||||
}
|
||||
|
||||
module.exports = { add, remove, list }
|
||||
47
bot/src/model/guildConfig.js
Normal file
47
bot/src/model/guildConfig.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Per-guild key/value config the bot owns (see guild_config in
|
||||
// server/db/schema.sql). Generic get/set now; filters/schedules/role-menu
|
||||
// config reuses this same table in later phases.
|
||||
const db = require('../db')
|
||||
|
||||
const MOD_LOG_CHANNEL_KEY = 'mod_log_channel_id'
|
||||
const AUTO_ROLE_KEY = 'auto_role_id'
|
||||
const INVITE_CHANNEL_KEY = 'invite_channel_id'
|
||||
const NEWS_CHANNEL_KEY = 'news_channel_id'
|
||||
|
||||
async function get(guildId, key) {
|
||||
const rows = await db.query('SELECT value FROM guild_config WHERE guild_id = ? AND `key` = ? LIMIT 1', [guildId, key])
|
||||
return rows[0] ? rows[0].value : null
|
||||
}
|
||||
|
||||
async function set(guildId, key, value) {
|
||||
await db.query(
|
||||
`INSERT INTO guild_config (guild_id, \`key\`, value) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
||||
[guildId, key, value],
|
||||
)
|
||||
}
|
||||
|
||||
const getModLogChannelId = (guildId) => get(guildId, MOD_LOG_CHANNEL_KEY)
|
||||
const setModLogChannelId = (guildId, channelId) => set(guildId, MOD_LOG_CHANNEL_KEY, channelId)
|
||||
|
||||
const getAutoRoleId = (guildId) => get(guildId, AUTO_ROLE_KEY)
|
||||
const setAutoRoleId = (guildId, roleId) => set(guildId, AUTO_ROLE_KEY, roleId)
|
||||
|
||||
const getInviteChannelId = (guildId) => get(guildId, INVITE_CHANNEL_KEY)
|
||||
const setInviteChannelId = (guildId, channelId) => set(guildId, INVITE_CHANNEL_KEY, channelId)
|
||||
|
||||
const getNewsChannelId = (guildId) => get(guildId, NEWS_CHANNEL_KEY)
|
||||
const setNewsChannelId = (guildId, channelId) => set(guildId, NEWS_CHANNEL_KEY, channelId)
|
||||
|
||||
module.exports = {
|
||||
get,
|
||||
set,
|
||||
getModLogChannelId,
|
||||
setModLogChannelId,
|
||||
getAutoRoleId,
|
||||
setAutoRoleId,
|
||||
getInviteChannelId,
|
||||
setInviteChannelId,
|
||||
getNewsChannelId,
|
||||
setNewsChannelId,
|
||||
}
|
||||
29
bot/src/model/inviteLog.js
Normal file
29
bot/src/model/inviteLog.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, channelId, inviteCode, triggeredBy, triggeredByTag }) {
|
||||
const res = await db.query(
|
||||
`INSERT INTO invite_log (guild_id, channel_id, invite_code, triggered_by, triggered_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, inviteCode, triggeredBy || null, triggeredByTag || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// The active (not-yet-revoked) invite for a guild, if any.
|
||||
async function getCurrent(guildId) {
|
||||
const rows = await db.query(
|
||||
'SELECT * FROM invite_log WHERE guild_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
|
||||
[guildId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function markRevoked(id) {
|
||||
await db.query('UPDATE invite_log SET revoked_at = NOW() WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
async function list(guildId, limit = 10) {
|
||||
return db.query('SELECT * FROM invite_log WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', [guildId, limit])
|
||||
}
|
||||
|
||||
module.exports = { record, getCurrent, markRevoked, list }
|
||||
14
bot/src/model/memberEvents.js
Normal file
14
bot/src/model/memberEvents.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
|
||||
// the moderation dashboard's members feed + invite-usage view. Schema in
|
||||
// server/db/schema.sql (member_events).
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
|
||||
await db.query(
|
||||
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
17
bot/src/model/roleMenus.js
Normal file
17
bot/src/model/roleMenus.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function add({ guildId, channelId, messageId, mapping, createdBy }) {
|
||||
await db.query(
|
||||
`INSERT INTO role_menus (guild_id, channel_id, message_id, mapping, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, messageId, JSON.stringify(mapping), createdBy || null],
|
||||
)
|
||||
}
|
||||
|
||||
async function getByMessageId(messageId) {
|
||||
const rows = await db.query('SELECT * FROM role_menus WHERE message_id = ? LIMIT 1', [messageId])
|
||||
if (!rows[0]) return null
|
||||
return { ...rows[0], mapping: JSON.parse(rows[0].mapping) }
|
||||
}
|
||||
|
||||
module.exports = { add, getByMessageId }
|
||||
57
bot/src/model/scheduledMessages.js
Normal file
57
bot/src/model/scheduledMessages.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function addRecurring({ guildId, channelId, content, cronExpression, createdBy, createdByTag }) {
|
||||
const res = await db.query(
|
||||
`INSERT INTO scheduled_messages (guild_id, channel_id, content, cron_expression, created_by, created_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, content, cronExpression, createdBy || null, createdByTag || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function addOnce({ guildId, channelId, content, runAt, createdBy, createdByTag }) {
|
||||
const res = await db.query(
|
||||
`INSERT INTO scheduled_messages (guild_id, channel_id, content, run_at, created_by, created_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, content, runAt, createdBy || null, createdByTag || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Returns true if a row was actually removed (scoped to the guild so one
|
||||
// guild can't remove another's rows).
|
||||
async function remove(guildId, id) {
|
||||
const res = await db.query('DELETE FROM scheduled_messages WHERE id = ? AND guild_id = ?', [id, guildId])
|
||||
return Number(res.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
async function list(guildId) {
|
||||
return db.query(
|
||||
`SELECT id, channel_id, content, cron_expression, run_at, enabled, sent_at FROM scheduled_messages
|
||||
WHERE guild_id = ? ORDER BY id ASC`,
|
||||
[guildId],
|
||||
)
|
||||
}
|
||||
|
||||
// All enabled recurring rows across every guild the bot serves — v1 only
|
||||
// ever has one, but the scheduler doesn't need to special-case that.
|
||||
async function listEnabledRecurring() {
|
||||
return db.query(
|
||||
`SELECT id, guild_id, channel_id, content, cron_expression FROM scheduled_messages
|
||||
WHERE cron_expression IS NOT NULL AND enabled = 1`,
|
||||
)
|
||||
}
|
||||
|
||||
// One-off rows due to post right now.
|
||||
async function listDueOneOff() {
|
||||
return db.query(
|
||||
`SELECT id, guild_id, channel_id, content FROM scheduled_messages
|
||||
WHERE run_at IS NOT NULL AND sent_at IS NULL AND enabled = 1 AND run_at <= NOW()`,
|
||||
)
|
||||
}
|
||||
|
||||
async function markSent(id) {
|
||||
await db.query('UPDATE scheduled_messages SET sent_at = NOW() WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
module.exports = { addRecurring, addOnce, remove, list, listEnabledRecurring, listDueOneOff, markSent }
|
||||
14
bot/src/model/spamHits.js
Normal file
14
bot/src/model/spamHits.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
|
||||
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
|
||||
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, spamType, discordUserId, username, channelId }) {
|
||||
await db.query(
|
||||
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[guildId, spamType, discordUserId, username || null, channelId || null],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
26
bot/src/model/tempRoles.js
Normal file
26
bot/src/model/tempRoles.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const db = require('../db')
|
||||
|
||||
// Upsert — re-granting the same temp role refreshes its expiry instead of
|
||||
// creating a duplicate row (see UNIQUE(guild,user,role) in schema.sql).
|
||||
async function add({ guildId, userId, roleId, expiresAt, createdBy }) {
|
||||
await db.query(
|
||||
`INSERT INTO temp_roles (guild_id, user_id, role_id, expires_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), created_by = VALUES(created_by)`,
|
||||
[guildId, userId, roleId, expiresAt, createdBy || null],
|
||||
)
|
||||
}
|
||||
|
||||
async function remove(guildId, userId, roleId) {
|
||||
await db.query('DELETE FROM temp_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?', [guildId, userId, roleId])
|
||||
}
|
||||
|
||||
async function listExpired() {
|
||||
return db.query('SELECT id, guild_id, user_id, role_id FROM temp_roles WHERE expires_at <= NOW()')
|
||||
}
|
||||
|
||||
async function removeById(id) {
|
||||
await db.query('DELETE FROM temp_roles WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
module.exports = { add, remove, listExpired, removeById }
|
||||
26
bot/src/model/warnings.js
Normal file
26
bot/src/model/warnings.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Standing warnings (separate from mod_actions so /warnings can list a
|
||||
// user's active warnings). expires_at is always NULL for now — decay/escalation
|
||||
// (e.g. "3 active warns -> auto-mute") is deferred past Phase 2, see
|
||||
// warn.command.js.
|
||||
const db = require('../db')
|
||||
|
||||
async function add({ guildId, targetUserId, targetTag, staffUserId, staffTag, reason }) {
|
||||
await db.query(
|
||||
`INSERT INTO warnings (guild_id, target_user_id, target_tag, staff_user_id, staff_tag, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, targetUserId, targetTag || null, staffUserId, staffTag || null, reason || null],
|
||||
)
|
||||
}
|
||||
|
||||
// Active = not expired. Every row is active today since expires_at is never
|
||||
// set, but the query is written to already respect it once decay lands.
|
||||
async function listActive(guildId, targetUserId) {
|
||||
return db.query(
|
||||
`SELECT id, reason, staff_tag, created_at FROM warnings
|
||||
WHERE guild_id = ? AND target_user_id = ? AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC`,
|
||||
[guildId, targetUserId],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { add, listActive }
|
||||
48
bot/src/roles/tempRoleSweeper.js
Normal file
48
bot/src/roles/tempRoleSweeper.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Once-a-minute sweep for expired temp_roles: removes the Discord role (best
|
||||
// effort — the member/guild/role may already be gone) then deletes the row
|
||||
// regardless, so a stale row can never block future re-grants of the same
|
||||
// role to the same member.
|
||||
const cron = require('node-cron')
|
||||
|
||||
const tempRoles = require('../model/tempRoles')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('temproles')
|
||||
|
||||
let client = null
|
||||
let task = null
|
||||
|
||||
async function sweep() {
|
||||
try {
|
||||
const expired = await tempRoles.listExpired()
|
||||
for (const row of expired) {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(row.guild_id)
|
||||
const member = await guild.members.fetch(row.user_id).catch(() => null)
|
||||
if (member) await member.roles.remove(row.role_id).catch(() => {})
|
||||
} catch (err) {
|
||||
log.warn('failed to remove expired temp role', { message: err.message, roleId: row.role_id, userId: row.user_id })
|
||||
} finally {
|
||||
await tempRoles.removeById(row.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('temp role sweep failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function start(discordClient) {
|
||||
client = discordClient
|
||||
task = cron.schedule('* * * * *', sweep)
|
||||
log.info('temp role sweeper started')
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (task) {
|
||||
task.stop()
|
||||
task = null
|
||||
}
|
||||
client = null
|
||||
}
|
||||
|
||||
module.exports = { start, stop }
|
||||
83
bot/src/scheduler/scheduler.js
Normal file
83
bot/src/scheduler/scheduler.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// Recurring + one-off scheduled channel messages. Recurring rows are each
|
||||
// registered as their own node-cron task; one-off rows are picked up by a
|
||||
// once-a-minute sweep that checks for anything due and marks it sent so it
|
||||
// never reposts. Needs a live discord.js Client to actually send — wired up
|
||||
// by discordManager.js (start() once the client is ready, stop() alongside
|
||||
// client teardown).
|
||||
const cron = require('node-cron')
|
||||
|
||||
const scheduledMessages = require('../model/scheduledMessages')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('scheduler')
|
||||
|
||||
let discordClient = null
|
||||
const recurringTasks = new Map() // id -> node-cron ScheduledTask
|
||||
let sweepTask = null
|
||||
|
||||
async function sendToChannel(channelId, content) {
|
||||
try {
|
||||
const channel = await discordClient.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) {
|
||||
log.warn('scheduled message skipped — channel missing or not text-based', { channelId })
|
||||
return
|
||||
}
|
||||
await channel.send({ content })
|
||||
log.info('sent scheduled message', { channelId })
|
||||
} catch (err) {
|
||||
log.warn('failed to send scheduled message', { channelId, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecurring() {
|
||||
for (const task of recurringTasks.values()) task.stop()
|
||||
recurringTasks.clear()
|
||||
|
||||
const rows = await scheduledMessages.listEnabledRecurring()
|
||||
for (const row of rows) {
|
||||
if (!cron.validate(row.cron_expression)) {
|
||||
log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression })
|
||||
continue
|
||||
}
|
||||
const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content))
|
||||
recurringTasks.set(row.id, task)
|
||||
}
|
||||
log.info('loaded recurring scheduled messages', { count: recurringTasks.size })
|
||||
}
|
||||
|
||||
async function sweepDueOneOff() {
|
||||
try {
|
||||
const due = await scheduledMessages.listDueOneOff()
|
||||
for (const row of due) {
|
||||
await sendToChannel(row.channel_id, row.content)
|
||||
await scheduledMessages.markSent(row.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('one-off sweep failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function start(client) {
|
||||
discordClient = client
|
||||
await loadRecurring()
|
||||
sweepTask = cron.schedule('* * * * *', sweepDueOneOff)
|
||||
log.info('scheduler started')
|
||||
}
|
||||
|
||||
// Called by /schedule after any add/remove so changes apply without a restart.
|
||||
async function refresh() {
|
||||
if (!discordClient) return
|
||||
await loadRecurring()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
for (const task of recurringTasks.values()) task.stop()
|
||||
recurringTasks.clear()
|
||||
if (sweepTask) {
|
||||
sweepTask.stop()
|
||||
sweepTask = null
|
||||
}
|
||||
discordClient = null
|
||||
}
|
||||
|
||||
module.exports = { start, stop, refresh }
|
||||
53
bot/src/server.js
Normal file
53
bot/src/server.js
Normal file
@@ -0,0 +1,53 @@
|
||||
require('dotenv').config()
|
||||
|
||||
const app = require('./app')
|
||||
const bootstrap = require('./bootstrap')
|
||||
const createLogger = require('./utils/logger')
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const brand = require('./brand')
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const log = createLogger('server')
|
||||
const PORT = Number(process.env.PORT) || 4100
|
||||
const HOST = '0.0.0.0'
|
||||
|
||||
async function start() {
|
||||
log.info(`starting ${brand.name} bot v${pkg.version}`, {
|
||||
node: process.version,
|
||||
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||
})
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
await bootstrap()
|
||||
|
||||
setupShutdown(server)
|
||||
}
|
||||
|
||||
function setupShutdown(server) {
|
||||
let closing = false
|
||||
const shutdown = async (signal) => {
|
||||
if (closing) return
|
||||
closing = true
|
||||
log.warn(`${signal} received — shutting down gracefully`)
|
||||
server.close(() => log.info('internal API closed'))
|
||||
await discordManager.stop()
|
||||
await createLogger.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('uncaughtException', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
log.error('failed to start bot', err)
|
||||
process.exit(1)
|
||||
})
|
||||
42
bot/src/site/siteApiClient.js
Normal file
42
bot/src/site/siteApiClient.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Read-only client for the main site's PUBLIC API (no shared secret — this is
|
||||
// the same unauthenticated data any visitor's browser can fetch). Used by
|
||||
// /wiki (search) and /announce (re-post an existing news item). Distinct from
|
||||
// botInternalClient.js, which is the shared-secret-gated server<->bot channel.
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('site-api')
|
||||
|
||||
const BASE_URL = (process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public').replace(/\/+$/, '')
|
||||
const TIMEOUT_MS = 5000
|
||||
|
||||
async function call(path) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, { signal: controller.signal })
|
||||
const data = await res.json().catch(() => null)
|
||||
// Public content routes 503 with this shape while the site is in
|
||||
// maintenance mode (see server/src/middleware/siteMode.js) — surface it
|
||||
// distinctly so commands can show a clear message instead of a generic error.
|
||||
if (res.status === 503 && data?.mode === 'maintenance') {
|
||||
return { ok: false, maintenance: true, message: data.message }
|
||||
}
|
||||
if (!res.ok) return { ok: false, error: `site responded ${res.status}` }
|
||||
return { ok: true, data }
|
||||
} catch (err) {
|
||||
log.warn('site API call failed', { path, message: err.message })
|
||||
return { ok: false, error: err.message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function getNewsPost(idOrSlug) {
|
||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||
}
|
||||
|
||||
function searchWiki(query) {
|
||||
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
module.exports = { getNewsPost, searchWiki }
|
||||
16
bot/src/utils/duration.js
Normal file
16
bot/src/utils/duration.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds.
|
||||
// Returns null for anything unparseable. Discord's own timeout API caps at 28
|
||||
// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input.
|
||||
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
||||
|
||||
const MAX_TIMEOUT_MS = 28 * 86_400_000
|
||||
|
||||
function parseDuration(input) {
|
||||
if (!input) return null
|
||||
const match = /^(\d+)\s*([smhd])$/i.exec(input.trim())
|
||||
if (!match) return null
|
||||
const [, amount, unit] = match
|
||||
return Number(amount) * UNIT_MS[unit.toLowerCase()]
|
||||
}
|
||||
|
||||
module.exports = { parseDuration, MAX_TIMEOUT_MS }
|
||||
97
bot/src/utils/logger.js
Normal file
97
bot/src/utils/logger.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Dual-transport logger: writes to the console AND to a log file.
|
||||
// Levels: error | warn | info | debug.
|
||||
// LOG_LEVEL console verbosity (default info)
|
||||
// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk)
|
||||
// LOG_TO_FILE enable file logging (default true)
|
||||
// LOG_DIR log directory (default <bot>/logs)
|
||||
// LOG_FILE log file name (default bot.log)
|
||||
//
|
||||
// Copied from server/src/utils/logger.js rather than shared — the bot is an
|
||||
// independently deployable process with its own package.json/Dockerfile.
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
|
||||
|
||||
const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
|
||||
const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug
|
||||
|
||||
// Color only on an interactive TTY — never in files or Docker logs.
|
||||
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null
|
||||
const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' }
|
||||
const RESET = '\x1b[0m'
|
||||
|
||||
// ── File transport ────────────────────────────────────────────────────
|
||||
const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false'
|
||||
let fileStream = null
|
||||
let logFilePath = null
|
||||
|
||||
if (fileEnabled) {
|
||||
try {
|
||||
const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
logFilePath = path.join(dir, process.env.LOG_FILE || 'bot.log')
|
||||
fileStream = fs.createWriteStream(logFilePath, { flags: 'a' })
|
||||
fileStream.on('error', (err) => {
|
||||
process.stderr.write(`[logger] file logging disabled: ${err.message}\n`)
|
||||
fileStream = null
|
||||
})
|
||||
} catch (err) {
|
||||
process.stderr.write(`[logger] could not open log file: ${err.message}\n`)
|
||||
fileStream = null
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(meta) {
|
||||
if (meta == null) return ''
|
||||
if (typeof meta === 'string') return meta
|
||||
if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack })
|
||||
try {
|
||||
return JSON.stringify(meta)
|
||||
} catch {
|
||||
return String(meta)
|
||||
}
|
||||
}
|
||||
|
||||
function emit(level, tag, msg, meta) {
|
||||
const levelNum = LEVELS[level]
|
||||
if (levelNum === undefined) return
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const lvl = level.toUpperCase().padEnd(5)
|
||||
const label = tag ? ` [${tag}]` : ''
|
||||
const metaStr = meta === undefined ? '' : ` ${fmt(meta)}`
|
||||
const plain = `${ts} ${lvl}${label} ${msg}${metaStr}`
|
||||
|
||||
// Console transport
|
||||
if (levelNum <= consoleThreshold) {
|
||||
const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain
|
||||
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout
|
||||
stream.write(`${line}\n`)
|
||||
}
|
||||
|
||||
// File transport (plain text, no color)
|
||||
if (fileStream && levelNum <= fileThreshold) {
|
||||
fileStream.write(`${plain}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
function createLogger(tag) {
|
||||
return {
|
||||
error: (msg, meta) => emit('error', tag, msg, meta),
|
||||
warn: (msg, meta) => emit('warn', tag, msg, meta),
|
||||
info: (msg, meta) => emit('info', tag, msg, meta),
|
||||
debug: (msg, meta) => emit('debug', tag, msg, meta),
|
||||
}
|
||||
}
|
||||
|
||||
// Flush and close the file stream (called on graceful shutdown).
|
||||
createLogger.close = () =>
|
||||
new Promise((resolve) => {
|
||||
if (fileStream) fileStream.end(resolve)
|
||||
else resolve()
|
||||
})
|
||||
|
||||
createLogger.emit = emit
|
||||
createLogger.logFilePath = logFilePath
|
||||
module.exports = createLogger
|
||||
15
brand/README.md
Normal file
15
brand/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Brand assets (per-instance)
|
||||
|
||||
This directory is bind-mounted into the container at `/app/brand` (see
|
||||
`docker-compose.yml`). Drop instance branding images here and point the matching
|
||||
`BRAND_*` env vars at them, e.g.:
|
||||
|
||||
```
|
||||
BRAND_LOGO=/brand/logo.png
|
||||
BRAND_HERO=/brand/hero.png
|
||||
BRAND_FAVICON=/brand/favicon.ico
|
||||
```
|
||||
|
||||
Leave the vars blank to use the built-in defaults (the hero falls back to a
|
||||
neutral built-in image; no logo/favicon is injected). Nothing here is required
|
||||
for the app to run — it renders cleanly with an empty `brand/`.
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UOMysticmoon</title>
|
||||
<meta name="description" content="UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<title>Runic Gateway</title>
|
||||
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
18
client/package-lock.json
generated
18
client/package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "uomysticmoon-client",
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "uomysticmoon-client",
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"diff": "^5.2.2",
|
||||
@@ -1483,6 +1484,19 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
|
||||
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "uomysticmoon-client",
|
||||
"name": "runic-gateway-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"diff": "^5.2.2",
|
||||
|
||||
BIN
client/public/assets/img/favicon.ico
Normal file
BIN
client/public/assets/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
client/public/assets/img/runic-emblem.png
Normal file
BIN
client/public/assets/img/runic-emblem.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -3,6 +3,8 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||
import RequireAuth from './components/RequireAuth.jsx'
|
||||
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||
import RoleGate from './components/RoleGate.jsx'
|
||||
|
||||
// Public
|
||||
import Portal from './routes/public/Portal.jsx'
|
||||
@@ -14,26 +16,74 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import 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'
|
||||
|
||||
// Admin
|
||||
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
||||
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
||||
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
||||
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
|
||||
import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
||||
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
||||
import 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'
|
||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
import Appeals from './routes/admin/views/Appeals.jsx'
|
||||
|
||||
// Player portal
|
||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
||||
import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SiteProvider>
|
||||
<Routes>
|
||||
{/* Public site — gated by maintenance mode (admins preview through it) */}
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
||||
<Route path="/" element={<Portal />} />
|
||||
|
||||
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||
<Route
|
||||
element={
|
||||
<MaintenanceGate>
|
||||
@@ -41,7 +91,6 @@ export default function App() {
|
||||
</MaintenanceGate>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Portal />} />
|
||||
<Route path="/site" element={<Website />} />
|
||||
<Route path="/site/news" element={<News />} />
|
||||
<Route path="/site/screenshots" element={<Screenshots />} />
|
||||
@@ -50,10 +99,29 @@ export default function App() {
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<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
|
||||
above (React Router ranks static routes over this dynamic one). */}
|
||||
<Route path="/:slug" element={<CmsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
||||
preview link works regardless of site mode. */}
|
||||
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route
|
||||
@@ -66,14 +134,75 @@ export default function App() {
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostsAdmin />} />
|
||||
<Route path="pages" element={<PagesAdmin />} />
|
||||
<Route path="pages/new" element={<PageBuilder />} />
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path="moderation"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<Outlet />
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<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={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="users/:id" element={<UserDetail />} />
|
||||
<Route path="invites" element={<InvitesAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
}
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</SiteProvider>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// same-origin API (/api/v1) — proxied to the Express server in dev.
|
||||
const BASE = '/api/v1'
|
||||
|
||||
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
|
||||
// callers can append it to a path without a dangling "?".
|
||||
const withQs = (s) => (s ? `?${s}` : '')
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, message, body) {
|
||||
super(message)
|
||||
@@ -41,8 +45,57 @@ function safeParse(text) {
|
||||
export const api = {
|
||||
// ----- auth -----
|
||||
me: () => req('/auth/me'),
|
||||
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
|
||||
// `extra` carries the honeypot field (and any future login fields).
|
||||
login: (username, password, extra = {}) =>
|
||||
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||
// Public self-registration (player accounts). `extra` carries the honeypot +
|
||||
// optional email. Returns { user } and sets the session cookie on success.
|
||||
register: (username, password, extra = {}) =>
|
||||
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
||||
// Email invites (public, token-gated accept).
|
||||
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||
acceptInvite: (token, username, password, extra = {}) =>
|
||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||
// 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
|
||||
// sets the new password and revokes all sessions (the user then signs in fresh).
|
||||
forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
|
||||
getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
|
||||
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). `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'),
|
||||
// Active mobile device sessions (role-agnostic self-service under /auth/me).
|
||||
// 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'),
|
||||
@@ -55,24 +108,139 @@ export const api = {
|
||||
if (opts.tag) qs.set('tag', opts.tag)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
const s = qs.toString()
|
||||
return req(`/public/wiki${s ? `?${s}` : ''}`)
|
||||
return req(`/public/wiki${withQs(s)}`)
|
||||
},
|
||||
wikiCategories: () => req('/public/wiki/categories'),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
// is fetched by id + token.
|
||||
page: (slug) => req(`/public/pages/${slug}`),
|
||||
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||
|
||||
// ----- shard live data (uo-link) -----
|
||||
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
||||
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
||||
shard: {
|
||||
status: () => req('/public/shard/status'),
|
||||
feed: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.kind) qs.set('kind', opts.kind)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
const s = qs.toString()
|
||||
return req(`/public/shard/feed${withQs(s)}`)
|
||||
},
|
||||
economy: (limit) => {
|
||||
const q = limit ? `limit=${limit}` : ''
|
||||
return req(`/public/shard/economy${withQs(q)}`)
|
||||
},
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) => {
|
||||
const q = limit ? `limit=${limit}` : ''
|
||||
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
|
||||
},
|
||||
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
|
||||
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
||||
shardStreamUrl: `${BASE}/public/shard/stream`,
|
||||
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
||||
|
||||
// ----- admin -----
|
||||
admin: {
|
||||
dashboard: () => req('/admin/dashboard'),
|
||||
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
|
||||
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
|
||||
listPosts: (category) => {
|
||||
const q = category ? `category=${category}` : ''
|
||||
return req(`/admin/posts${withQs(q)}`)
|
||||
},
|
||||
getPost: (id) => req(`/admin/posts/${id}`),
|
||||
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
|
||||
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
|
||||
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
publishPost: (id, published) =>
|
||||
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
||||
// News announcement pipeline (town crier + Discord) status + per-leg retry.
|
||||
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
|
||||
retryAnnounceLeg: (id, leg) =>
|
||||
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
|
||||
uploadImage: (file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
@@ -84,6 +252,15 @@ export const api = {
|
||||
fd.append('image', file)
|
||||
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
// ----- CMS pages (block-based page builder) -----
|
||||
listPages: () => req('/admin/pages'),
|
||||
getPage: (id) => req(`/admin/pages/${id}`),
|
||||
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
|
||||
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
|
||||
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
|
||||
unprotectPage: (id, password) =>
|
||||
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
|
||||
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
|
||||
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
||||
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
||||
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
||||
@@ -104,10 +281,215 @@ export const api = {
|
||||
getSettings: () => req('/admin/settings'),
|
||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
listUsers: () => req('/admin/users'),
|
||||
getUser: (id) => req(`/admin/users/${id}`),
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
// 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) =>
|
||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
||||
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
||||
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
||||
// account) so the shared GameAccounts component works unchanged.
|
||||
userShard: (id) => ({
|
||||
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||
online: () => req(`/admin/users/${id}/shard/online`),
|
||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
||||
}),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/recent${withQs(s)}`)
|
||||
},
|
||||
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||
modMembers: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/members${withQs(s)}`)
|
||||
},
|
||||
modFilterHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/filter-hits${withQs(s)}`)
|
||||
},
|
||||
modSpamHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/spam-hits${withQs(s)}`)
|
||||
},
|
||||
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||
modUserActions: (discordId, params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`)
|
||||
},
|
||||
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||
addModNote: (discordId, data) =>
|
||||
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
||||
|
||||
// ----- moderation appeals (admin + moderator) -----
|
||||
getAppeals: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.status) qs.set('status', params.status)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/appeals${withQs(s)}`)
|
||||
},
|
||||
getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
|
||||
claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
|
||||
resolveAppeal: (id, data) =>
|
||||
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
|
||||
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
|
||||
|
||||
// ----- account security (self-service 2FA) -----
|
||||
getAccount: () => req('/admin/account'),
|
||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
|
||||
// ----- linked SSO identities (self-service) -----
|
||||
linkedIdentities: () => req('/admin/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- game account linking (self-service, staff) -----
|
||||
shard: {
|
||||
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/admin/shard/accounts'),
|
||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/admin/shard/sales'),
|
||||
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
||||
createAccount: (account, password) =>
|
||||
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
||||
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- Discord bot control (admin only) -----
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||
|
||||
// ----- uo-link sidecar control (admin only) -----
|
||||
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
// 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.
|
||||
shardOps: {
|
||||
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
||||
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
||||
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
||||
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
||||
pages: () => req('/admin/shard/pages'),
|
||||
respondPage: (id, data) =>
|
||||
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
||||
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
||||
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
|
||||
},
|
||||
|
||||
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||
getEmailConfig: () => req('/admin/email/config'),
|
||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||
emailConnectUrl: () => req('/admin/email/connect/start'),
|
||||
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
||||
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// ----- player self-service (role: 'player') -----
|
||||
// Mirrors the admin account methods but self-scoped under /player. The change
|
||||
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
||||
player: {
|
||||
getAccount: () => req('/player/account'),
|
||||
changeUsername: (username) =>
|
||||
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
linkedIdentities: () => req('/player/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- game account linking (uo-link) -----
|
||||
shard: {
|
||||
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/player/shard/accounts'),
|
||||
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/player/shard/sales'),
|
||||
houses: () => req('/player/shard/houses'), // the caller's own houses
|
||||
createAccount: (account, password) =>
|
||||
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
|
||||
// ----- moderation appeals (self-service) -----
|
||||
getMyAppeals: () => req('/player/appeals'),
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
|
||||
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
26
client/src/blocks/BlockRenderer.jsx
Normal file
26
client/src/blocks/BlockRenderer.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// Renders stored blocks via their registry component. Used by the public page
|
||||
// route, the draft preview, and (recursively) the two_column block. Kept
|
||||
// separate from the registry so both the renderer and the builder can import it.
|
||||
// Import the lookup from the registry directly (not ./index) to avoid a cycle:
|
||||
// index → types/twoColumn → BlockRenderer. The page route/builder import ./index,
|
||||
// which registers every block before anything renders.
|
||||
import { getBlock } from './registry.js'
|
||||
|
||||
/**
|
||||
* Render one block. A block with `visible === false` renders nothing (admins
|
||||
* hide blocks without deleting them). An unknown type also renders nothing —
|
||||
* server validation prevents storing one, so this only guards a client/server
|
||||
* registry skew rather than crashing the whole page.
|
||||
*/
|
||||
export default function BlockRenderer({ block }) {
|
||||
if (!block || block.visible === false) return null
|
||||
const def = getBlock(block.type)
|
||||
if (!def || !def.component) return null
|
||||
const Component = def.component
|
||||
return <Component props={block.props || {}} block={block} />
|
||||
}
|
||||
|
||||
/** Render an ordered array of blocks (array position = display order). */
|
||||
export function BlockList({ blocks }) {
|
||||
return (blocks || []).map((block) => <BlockRenderer key={block.id} block={block} />)
|
||||
}
|
||||
64
client/src/blocks/editorKit.jsx
Normal file
64
client/src/blocks/editorKit.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// Shared form controls for block editors, styled with the existing admin design
|
||||
// system (.field-label / .input / .select). Every block's editor is a
|
||||
// ({ props, onChange }) component; these keep the seven of them consistent and
|
||||
// short. onChange always receives the full next props object.
|
||||
|
||||
export function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">{label}</span>
|
||||
{children}
|
||||
{hint && (
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextField({ label, hint, value, onChange, placeholder, maxLength }) {
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) {
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={rows}
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ resize: 'vertical', fontFamily: 'inherit' }}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
// options: array of [value, label] tuples.
|
||||
export function SelectField({ label, hint, value, onChange, options }) {
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<select className="select" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
19
client/src/blocks/index.js
Normal file
19
client/src/blocks/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Client block registry entrypoint. Importing this module registers every
|
||||
// browser-side block definition (renderer + editor + palette entry) exactly
|
||||
// once, then re-exports the registry API. The page builder and the public page
|
||||
// renderer should import from HERE, not ./registry, so the definitions are
|
||||
// loaded before anything reads the registry.
|
||||
//
|
||||
// Wave 1 definitions are registered below as each block is built (spec build
|
||||
// order step 3), one import per block.
|
||||
|
||||
export * from './registry'
|
||||
|
||||
// ── Wave 1 block definitions (self-register on import) ─────────────────
|
||||
import './types/heading.jsx'
|
||||
import './types/richText.jsx'
|
||||
import './types/image.jsx'
|
||||
import './types/twoColumn.jsx'
|
||||
import './types/cta.jsx'
|
||||
import './types/divider.jsx'
|
||||
import './types/quote.jsx'
|
||||
84
client/src/blocks/registry.js
Normal file
84
client/src/blocks/registry.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Block registry (client side) — mirrors the server registry
|
||||
// (server/src/blocks/registry.js) but carries the browser-only concerns: the
|
||||
// React renderer, the admin edit form, and the palette icon/label. The page
|
||||
// builder's palette, drag-reorder canvas, per-block edit panel, and the public
|
||||
// page renderer all read from this registry, so adding a block later is one
|
||||
// entry here (plus its server-side schema entry) rather than edits scattered
|
||||
// across the builder and renderer.
|
||||
//
|
||||
// A registered definition looks like:
|
||||
// {
|
||||
// type: 'heading', // must match the server registry type
|
||||
// version: 1, // must match the server schema version
|
||||
// label: 'Heading', // palette display name
|
||||
// icon: 'heading', // palette icon key
|
||||
// component: HeadingBlock, // renderer: (props) => JSX
|
||||
// editor: HeadingEditor, // admin edit form: ({ props, onChange }) => JSX
|
||||
// defaults: () => ({ ... }), // starting props when a block is added
|
||||
// container: false, // true only for two_column
|
||||
// containerSlots: [], // ['left','right'] for two_column
|
||||
// }
|
||||
//
|
||||
// This module only defines the pattern; Wave 1 definitions register via
|
||||
// ./index.js as each block is built (spec build order step 3).
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// Kept in sync with the server's RESERVED_KEYS — the only top-level keys on a
|
||||
// stored block object. Exported so the builder can construct envelopes without
|
||||
// hard-coding the shape.
|
||||
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
|
||||
|
||||
/**
|
||||
* Register a block definition. Throws on a duplicate type — a programmer error
|
||||
* caught at module load, not runtime.
|
||||
* @param {object} def
|
||||
* @returns {object} the stored definition
|
||||
*/
|
||||
export function registerBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
|
||||
throw new Error('registerBlock: a block definition needs a string `type`')
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
const entry = {
|
||||
type: def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
label: def.label || def.type,
|
||||
icon: def.icon || null,
|
||||
component: def.component || null,
|
||||
editor: def.editor || null,
|
||||
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
|
||||
container: Boolean(def.container),
|
||||
containerSlots: def.containerSlots ? [...def.containerSlots] : [],
|
||||
}
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
export function getBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `type` is a registered block. */
|
||||
export function hasBlock(type) {
|
||||
return registry.has(type)
|
||||
}
|
||||
|
||||
/** @returns {object[]} all registered definitions (registration order). */
|
||||
export function listBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable block id. Called once when a block is added to the canvas;
|
||||
* never derived from array position, so a reorder keeps ids intact (they are the
|
||||
* React key and the future revision-history join point).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function makeBlockId() {
|
||||
const rand = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||
return `b_${rand}`
|
||||
}
|
||||
63
client/src/blocks/types/cta.jsx
Normal file
63
client/src/blocks/types/cta.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// cta block — a call-to-action button/link. Renders as an anchor styled with the
|
||||
// existing button system (primary / secondary).
|
||||
import { registerBlock } from '../registry'
|
||||
import { SelectField, TextField } from '../editorKit.jsx'
|
||||
|
||||
const STYLES = [
|
||||
['primary', 'Primary'],
|
||||
['secondary', 'Secondary'],
|
||||
]
|
||||
|
||||
function CtaBlock({ props }) {
|
||||
if (!props.url || !props.text) return null
|
||||
const style = props.style === 'secondary' ? 'secondary' : 'primary'
|
||||
// External links get a safe rel; same-origin relative links don't need it.
|
||||
const external = /^https?:\/\//i.test(props.url)
|
||||
return (
|
||||
<div className="page-cta-wrap">
|
||||
<a
|
||||
className={`btn btn-sq page-cta page-cta--${style}`}
|
||||
href={props.url}
|
||||
{...(external ? { rel: 'noopener noreferrer nofollow' } : {})}
|
||||
>
|
||||
{props.text}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CtaEditor({ props, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<TextField
|
||||
label="Button text"
|
||||
value={props.text}
|
||||
maxLength={100}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
<TextField
|
||||
label="URL"
|
||||
hint="A full https:// link or a same-site path like /wiki/getting-started."
|
||||
value={props.url}
|
||||
placeholder="https://…"
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Style"
|
||||
value={props.style || 'primary'}
|
||||
onChange={(style) => onChange({ ...props, style })}
|
||||
options={STYLES}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'cta',
|
||||
version: 1,
|
||||
label: 'Button',
|
||||
icon: '⇥',
|
||||
component: CtaBlock,
|
||||
editor: CtaEditor,
|
||||
defaults: () => ({ text: '', url: '', style: 'primary' }),
|
||||
})
|
||||
25
client/src/blocks/types/divider.jsx
Normal file
25
client/src/blocks/types/divider.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// divider block — a pure spacer / horizontal rule. No props, so its editor is
|
||||
// just a note.
|
||||
import { registerBlock } from '../registry'
|
||||
|
||||
function DividerBlock() {
|
||||
return <hr className="page-divider" />
|
||||
}
|
||||
|
||||
function DividerEditor() {
|
||||
return (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
A divider has no options — it adds a horizontal rule and spacing.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'divider',
|
||||
version: 1,
|
||||
label: 'Divider',
|
||||
icon: '—',
|
||||
component: DividerBlock,
|
||||
editor: DividerEditor,
|
||||
defaults: () => ({}),
|
||||
})
|
||||
46
client/src/blocks/types/heading.jsx
Normal file
46
client/src/blocks/types/heading.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// heading block — plain-text section heading (h1–h4). Text is rendered as text
|
||||
// (React escapes it); use rich_text for inline markup.
|
||||
import { registerBlock } from '../registry'
|
||||
import { SelectField, TextField } from '../editorKit.jsx'
|
||||
|
||||
const LEVELS = [
|
||||
['h1', 'Heading 1'],
|
||||
['h2', 'Heading 2'],
|
||||
['h3', 'Heading 3'],
|
||||
['h4', 'Heading 4'],
|
||||
]
|
||||
const VALID = ['h1', 'h2', 'h3', 'h4']
|
||||
|
||||
function HeadingBlock({ props }) {
|
||||
const Tag = VALID.includes(props.level) ? props.level : 'h2'
|
||||
return <Tag className="page-heading">{props.text}</Tag>
|
||||
}
|
||||
|
||||
function HeadingEditor({ props, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SelectField
|
||||
label="Level"
|
||||
value={props.level || 'h2'}
|
||||
onChange={(level) => onChange({ ...props, level })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
<TextField
|
||||
label="Text"
|
||||
value={props.text}
|
||||
maxLength={200}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'heading',
|
||||
version: 1,
|
||||
label: 'Heading',
|
||||
icon: 'H',
|
||||
component: HeadingBlock,
|
||||
editor: HeadingEditor,
|
||||
defaults: () => ({ level: 'h2', text: '' }),
|
||||
})
|
||||
100
client/src/blocks/types/image.jsx
Normal file
100
client/src/blocks/types/image.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// image block — a single image with optional caption and alignment. Upload
|
||||
// reuses the shared admin uploader (returns { url }); the block stays URL-based
|
||||
// until the Wave 3 asset picker lands.
|
||||
import { useState } from 'react'
|
||||
import { registerBlock } from '../registry'
|
||||
import { api } from '../../api/client.js'
|
||||
import { SelectField, TextField } from '../editorKit.jsx'
|
||||
|
||||
const ALIGN = [
|
||||
['left', 'Left'],
|
||||
['center', 'Center'],
|
||||
['right', 'Right'],
|
||||
['full', 'Full width'],
|
||||
]
|
||||
const VALID = ['left', 'center', 'right', 'full']
|
||||
|
||||
function ImageBlock({ props }) {
|
||||
if (!props.src) return null
|
||||
const align = VALID.includes(props.alignment) ? props.alignment : 'center'
|
||||
return (
|
||||
<figure className={`page-image page-image--${align}`}>
|
||||
<img src={props.src} alt={props.alt || ''} />
|
||||
{props.caption && <figcaption>{props.caption}</figcaption>}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageEditor({ props, onChange }) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function onUpload(e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { url } = await api.admin.upload(file)
|
||||
onChange({ ...props, src: url })
|
||||
} catch (err) {
|
||||
setError(err.message || 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<span className="field-label">Image</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={onUpload}
|
||||
className="sans"
|
||||
style={{ color: 'var(--muted)', fontSize: '0.85rem', display: 'block' }}
|
||||
/>
|
||||
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading…</span>}
|
||||
{error && <span className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{error}</span>}
|
||||
{props.src && (
|
||||
<img
|
||||
src={props.src}
|
||||
alt=""
|
||||
style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TextField
|
||||
label="Alt text"
|
||||
hint="Describes the image for screen readers and when it fails to load."
|
||||
value={props.alt}
|
||||
maxLength={300}
|
||||
onChange={(alt) => onChange({ ...props, alt })}
|
||||
/>
|
||||
<TextField
|
||||
label="Caption (optional)"
|
||||
value={props.caption}
|
||||
maxLength={500}
|
||||
onChange={(caption) => onChange({ ...props, caption })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Alignment"
|
||||
value={props.alignment || 'center'}
|
||||
onChange={(alignment) => onChange({ ...props, alignment })}
|
||||
options={ALIGN}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerBlock({
|
||||
type: 'image',
|
||||
version: 1,
|
||||
label: 'Image',
|
||||
icon: '🖼',
|
||||
component: ImageBlock,
|
||||
editor: ImageEditor,
|
||||
defaults: () => ({ src: '', alt: '', caption: '', alignment: 'center' }),
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user