25 Commits

Author SHA1 Message Date
2b4c4c5235 Merge branch 'main' into feature/moderation-dashboard 2026-07-05 20:51:16 +00:00
3027bb0400 Capture member/filter/spam events for the dashboard (Phase 6b)
Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.

Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
  attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections

Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
  keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
  diffs it on join to find which invite was used — best-effort, never blocks
  auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
  inviteFilter now returns the offending code; detectSpam identifies which spam
  rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive

Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}

Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
  notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
  removed the coming-soon note

Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 10:36:09 -05:00
b0c0d1fe9b Add moderation dashboard, user history & notes (Phase 6a)
Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.

Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)

Server
- model/moderation: read mod_actions via the shared pool (documented read-only
  cross of the bot/server ownership boundary), correlate accounts through
  user_identities (provider='discord'), flag automated actions via
  staff_user_id === bot_config.application_id; pure reshaping helpers isolated
  in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
  requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators

Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
  lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges

Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.

Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 10:16:34 -05:00
f2691959ff Merge pull request 'Fix bot container inheriting site PORT/LOG_FILE from shared .env' (#41) from bugfix/bot-container-port-leak into main
Reviewed-on: UOM/website#41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 05:40:29 +00:00
60d2121b83 Fix bot container inheriting site PORT/LOG_FILE from shared .env
The app and bot services share env_file: .env, so the site's PORT=3000
leaked into the bot container. The bot code is `PORT || 4100`, so it
bound 3000 instead of 4100 — and the server's BOT_INTERNAL_URL
(http://bot:4100) then couldn't reach it, surfacing as "failed to fetch"
on the admin Discord Bot page even though the bot was otherwise healthy
and connected to Discord.

Pin PORT: 4100 on the bot service so it binds where the server expects.
Also override LOG_FILE: bot.log so the bot doesn't inherit the site's
LOG_FILE and write into app.log, keeping the two logs distinct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 00:39:16 -05:00
20d3fbf594 Merge pull request 'Audit and fix Swagger/OpenAPI accuracy; regenerate served spec' (#40) from docs/swagger-audit into main
Reviewed-on: UOM/website#40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 04:33:36 +00:00
f8db61025b Audit and fix Swagger/OpenAPI accuracy; regenerate served spec
The route-level annotations were 100% present, but the committed/served
spec (swagger-output.json) was stale and several response schemas had
drifted from the controllers. This aligns the docs with actual behavior
and regenerates the spec.

Served spec was stale (64/67 operations). Regenerating picks up three
routes that were added after the last generation:
  - POST /api/v1/auth/sso/totp
  - GET  /api/v1/admin/discord-bot/config
  - PUT  /api/v1/admin/discord-bot/config
plus a stale /auth/logout summary.

Response-shape corrections (annotation now matches controller output):
  - Mutation endpoints do NOT return the generic { message } envelope.
    Deletes echo { id } / { slug }; toggles return { deleted },
    { unlinked }, { totp_enabled }, or { ip, removed }. Documented as-is
    via new DeletedId/DeletedSlug/DeletedFlag/UnlinkedFlag/TotpState/
    UnbanResult components. (The API is intentionally inconsistent here;
    recorded rather than normalized — see follow-up note.)
  - POST /account/totp/setup: otpauth_url -> otpauthUrl (TotpSetup)
  - PUT  /admin/site-mode: { mode } -> { site_mode, changed_at, changed_by }
  - GET  /account: full User -> AccountStatus (id/username/role/totp_enabled)
  - GET  /account/identities: add linked_at (LinkedIdentity)
  - GET  /public/status: add status_message (PublicStatus)
  - POST /auth/sso/totp: user is SafeUser, not full User
  - GET  /dashboard: description/shape corrected (posts+users, no wiki)

Schema completeness:
  - Provider (public discovery): { id, name, icon, loginUrl, priority },
    not { id, name, kind }
  - ProviderConfig: add hasSecret, builtin, health (ProviderHealth)
  - Post: add excerpt, author_id, published_at
  - MobileTokenResponse.expiresIn: duration string ("15m"), not integer

Config: declare the Admin · Discord Bot tag (was used but undeclared).

Auth model and the internal/external boundary were verified correct and
left unchanged: cookie + bearer are both accepted on session routes (dual
security annotations are accurate), and /internal/* runs on a separate
listener already excluded from the scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:51:16 -05:00
2067028070 Merge pull request 'Enforce TOTP second factor on SSO login (#31)' (#39) from bugfix/sso-totp-bypass-31 into main
Reviewed-on: UOM/website#39
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 03:34:49 +00:00
03e62b56ad Enforce TOTP second factor on SSO login (#31)
SSO login minted a full session immediately, ignoring the account's
totp_enabled flag — so a 2FA admin with a linked Google/Discord/OIDC
identity could sign in without their authenticator code, silently
downgrading the account to single-factor (the strength of the IdP login).
The local password flow already gates on needsTotp(); SSO did not.

Wire SSO through the same staged-TOTP gate:

- ssoState: createTotpPending/verifyTotpPending + a short-lived httpOnly
  sso_totp cookie. The pending token carries stage:'totp' (session
  validation rejects it) + kind:'sso_totp' (scoped to the SSO endpoint)
  plus the resolved context (userId, provider, authMethod, returnTo).
- sso.controller: finishLogin now stages the challenge and redirects to
  /admin/login?sso_totp=1 instead of creating a session when the account
  has TOTP on. New finishSsoTotp verifies the code (backoff + bot-scoring
  on failure, mirroring loginTotp) and only then mints the session.
- sso.routes: POST /auth/sso/totp behind the same backoff/slow/limiter
  stack and code validation as the local TOTP endpoint.
- client: AdminLogin detects ?sso_totp=1 and completes over fetch via
  api.ssoLoginTotp; the challenge never touches the URL or JS.

Keeps the second factor httpOnly throughout, consistent with the SSO tx
cookie. 12 new tests; full suite 106/106.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:17:35 -05:00
15cf8ea286 Merge pull request 'Fix SSO flow-token / session type confusion (#32)' (#38) from bugfix/sso-token-confusion-32 into main
Reviewed-on: UOM/website#38
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 02:56:31 +00:00
5f62eccdd8 Fix SSO flow-token / session type confusion (#32)
sessionFromDecoded validated sessions with a blocklist — it rejected a
token only when `decoded.stage` was present (the TOTP challenge). Because
every JWT is signed with the same JWT_SECRET and distinguished only by
claims, the SSO transaction cookie (sso_tx, which carries kind:'sso_tx'
and id:'sso' but no stage) passed validation and was accepted as a bogus
{ userId:'sso' } session.

requireAuth's DB re-load blocked protected admin routes, but non-DB
identity checks were fooled — notably siteMode's maintenance-preview
bypass, which trusts any truthy getUserFromRequest. An attacker could
start an SSO flow to obtain an sso_tx cookie and replay it as the auth
cookie / Bearer token to bypass the maintenance gate. The broader risk
was latent: any future code path trusting attachSession/getUserFromRequest
without a DB round-trip inherited an auth bypass.

Make session validation positively typed: real sessions are now stamped
with typ:'session' (createSession + mintMobileTokens), and
sessionFromDecoded accepts a token only when that marker is present. As
belt-and-suspenders it also rejects any token carrying a non-session
marker (stage || kind). Flow/challenge tokens are never stamped, so they
can no longer be mistaken for sessions.

Note: existing web cookie sessions predating this change lack the typ
claim and will be rejected once — users re-login. Mobile clients recover
automatically on next refresh.

Adds regression tests: the sso_tx flow token and a bare identity token
are both rejected by validateSession / decodeIdentity / getUserFromRequest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:54:58 -05:00
e8a54d9ff7 Merge pull request 'Implement web session/token revocation (#30)' (#37) from bugfix/session-revocation-30 into main
Reviewed-on: UOM/website#37
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 02:08:15 +00:00
933206a1b8 Implement web session/token revocation (#30)
Web sessions were stateless JWTs with no server-side store: the revocation
hooks in session.service were stubs that only logged. As a result web logout
was client-side only (a copied cookie stayed valid until natural JWT expiry)
and a password change never invalidated existing sessions. The mobile bearer
flow already had revocable, DB-stored tokens; this brings the web/cookie flow
to parity.

Two-layer revocation, both enforced in requireAuth (which already loads the
fresh user row each request):

- Per-session denylist: new `revoked_sessions` table keyed on the JWT `jti`
  (already minted per session). A single logout adds this session's jti;
  rows self-expire at the token's own exp and are pruned on boot. New model
  `revokedSessions` mirrors the `mobileSessions` db/model split.
- Per-user cutoff: new `users.tokens_valid_after` column. A password change
  (and the new `invalidateSessions` helper) bumps it to NOW(); any token whose
  iat is at or before the cutoff is rejected. The comparison is inclusive so a
  token minted in the same wall-clock second as the change is still revoked.

Wiring:
- session.service: revokeSession / invalidateSession / invalidateAllUserSessions
  now delegate to the stores; sessions carry `expiresAt` (JWT exp) so logout can
  set a self-pruning denylist row.
- /logout gains best-effort attachSession so the controller can revoke this
  session's jti and log auth.logout; stays a no-op for anonymous callers.
- users.model.update bumps the cutoff whenever the password hash is rotated.
- schema.sql: revoked_sessions table + tokens_valid_after column, added to the
  CREATE and to the idempotent migration block (ensureSchema on boot).

Verified end-to-end against the local dev DB: a captured cookie is rejected
after logout, and an existing session is rejected after a password change while
re-login with the new password succeeds. Full server test suite green (96).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 21:06:50 -05:00
1cfb79f5ae Merge pull request 'Isolate internal bot-config route from the public listener (#33)' (#36) from bugfix/internal-token-endpoint-33 into main
Reviewed-on: UOM/website#36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 22:50:05 +00:00
3ef84b41ef Merge branch 'main' into bugfix/internal-token-endpoint-33 2026-07-04 22:49:33 +00:00
5df943095d Isolate internal bot-config route from the public listener (#33)
The GET /internal/bot-config route returns the DECRYPTED Discord bot
token and was mounted on the same Express app / port 3000 that Pangolin
proxies publicly. Its only guard was the BOT_INTERNAL_KEY shared secret,
and .env.example shipped a placeholder default — so a forwarded path or a
weak/unrotated key would expose the plaintext token to the internet.

Move server<->bot internal traffic onto its own listener and fail fast on
a weak key:

- Add server/src/internalApp.js: a standalone Express app mounting
  requireInternalKey + /internal (and a no-secret /health), mirroring the
  bot's unpublished port-4100 pattern.
- server.js starts a second listener on INTERNAL_PORT (default 3001),
  closed on graceful shutdown.
- Remove the /internal mount from the public v1.router; the public app now
  404s /api/v1/internal/bot-config even with a valid key.
- Fail fast: new utils/botInternalKey.js rejects an empty, placeholder, or
  <16-char BOT_INTERNAL_KEY — fatal in production (exit 1), warning in dev.
- docker-compose: bot SITE_INTERNAL_URL -> app:3001/internal/bot-config;
  document that INTERNAL_PORT stays unpublished.
- .env.example (root/server/bot): document INTERNAL_PORT, the fail-fast
  behavior, and a defense-in-depth Pangolin deny rule for /api/v1/internal.

Tests: add requireInternalKey.test.js and botInternalKey.test.js
(node --test: 93 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 17:35:07 -05:00
bb5cc68c54 Merge pull request 'Add Discord bot: moderation, filters, scheduling, roles, invites, site integration' (#29) from feature/discord-bot into main
Reviewed-on: UOM/website#29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 21:19:41 +00:00
17c1eb07e8 Merge branch 'main' into feature/discord-bot 2026-07-04 21:19:23 +00:00
7a21cc636c Add Discord bot (moderation, filters, scheduling, roles, invites, site integration)
Standalone bot/ service (its own package.json/Dockerfile) managed entirely
through a new admin-only Discord Bot panel — token stored encrypted in the
DB and pushed to the bot process in-memory, never an env var. Built in
phases, each independently verified against a live Discord guild:

- Bot skeleton: gateway connection, internal shared-secret API, self-heals
  on its own restart by pulling config from the site
- Moderation core: /ban /kick /mute /warn /warnings + mod-log channel
- Word/invite/spam filtering with leetspeak-resistant normalization and a
  staff role/channel allowlist
- Scheduled messages: recurring (cron) and one-off channel posts
- Role assignment: button role menus, auto-role on join, temp roles,
  bulk role ops
- Auto-rotating primary invite with an audit log
- Site integration: news-publish -> Discord announce webhook, manual
  /announce, read-only /wiki search

Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb
driver defaulted to timezone 'local', silently mis-serializing bound Date
params by the host's local offset instead of the DB's UTC session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:54:41 -05:00
ad7aebb3ba Merge pull request 'Hero editor: fullscreen landing, remove two-card row, quick links into hero' (#28) from feature/hero-fullscreen-landing into main
Reviewed-on: UOM/website#28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 02:55:50 +00:00
0318d6fe9f Make hero the full landing page; move quick links into hero editor
Remove the two-card destination row and the below-hero quick-links nav
from the portal so the hero fills the viewport with nothing rendered
after it. The 5 quick links (News, Screenshots, Five on Friday,
Monthly Newsletter, About) move into the hero editor as a third
buttons element in defaultLayout(), reusing the existing buttons
element type so they stay fully editable with no schema changes.

Also drop overflow:hidden on the hero section: on mobile, 100vh can
compute smaller than window.innerHeight, and with overflow hidden the
wrapped quick-links text was getting clipped at the bottom edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:49:45 -05:00
433e02d3ef Merge pull request 'Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)' (#27) from feature/swagger-docs into main
Reviewed-on: UOM/website#27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 20:30:14 +00:00
a1f0675577 Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)
Generate an OpenAPI 3.0 spec from route annotations and serve it with
Swagger UI so the full REST API is browsable and testable.

- Add swagger-ui-express (runtime) and swagger-autogen (dev) deps, plus
  an `npm run swagger` script.
- server/swagger/swagger.js: generator config with API metadata, servers,
  14 tag groups, cookie + bearer security schemes, and 28 reusable
  component schemas. Follows the Express mount chain from src/app.js so
  generated paths are fully-qualified (/api/v1/...).
- Annotate every route (auth, mobile, sso, public, admin, health) with
  #swagger tags/summaries/parameters/request bodies/security and the
  actual response codes each handler returns (400/401/403/404/409/429/
  302/502, multipart uploads).
- Serve Swagger UI at /api/docs and the raw spec at /api/docs.json,
  guarded so a missing spec disables docs instead of crashing.
- Commit the generated swagger-output.json so docs work with no build
  step; swagger-autogen stays dev-only and is not needed at runtime.
- README: new "API documentation (Swagger)" section plus tech-stack and
  project-structure entries.

Covers 51 paths / 64 operations. Existing test suite (83) still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:28:13 -05:00
d7fb274bad Merge pull request 'Hero editor: scale text-block fonts with the resize handle (#25)' (#26) from enhancement/hero into main
Reviewed-on: UOM/website#26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 20:01:50 +00:00
6af85c30b6 Hero editor: scale text block fonts with the resize handle (#25)
The text_block corner handle previously only changed the wrap width, so
the font size never tracked the box — making the editor un-WYSIWYG and
awkward to tune. Now dragging the handle scales every line's font
proportionally with the box, acting as a zoom that preserves the
h1/h2/p size ratios and keeps each line's manually-set baseline.

- Add scaleFontSize(): numeric px sizes (floored at 6px) and simple
  rem/em/px strings scale by the box ratio; responsive clamp()/vw
  strings are left untouched so the default hero stays fluid.
- Snapshot the box width + lines at drag start so scaling is computed
  against the origin (no rounding drift mid-drag).
- Update the canvas hint to note the handle scales text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:59:15 -05:00
127 changed files with 16089 additions and 152 deletions

View File

@@ -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
}
]
}

View File

@@ -4,6 +4,10 @@
# 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,7 +16,8 @@ LOG_TO_FILE=true # set false for console-only
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
LOG_FILE=app.log
# Database (the values here are shared by the `db` and `app` containers)
# Database (the values here are shared by the `db`, `app`, and `bot` containers
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
@@ -58,3 +63,19 @@ CONTACT_TO=UOMysticmoon@gmail.com
# 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

View File

@@ -23,6 +23,7 @@ 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)
- [Environment variables](#environment-variables)
- [Security](#security)
- [Logging](#logging)
@@ -39,6 +40,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
| Deploy | Docker Compose, Pangolin reverse proxy |
---
@@ -57,6 +59,7 @@ UOMSITE/
│ │ ├─ 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/
@@ -207,7 +210,44 @@ npm start # node server → serves API + SPA at http://localhost:3
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
docs below for a per-endpoint reference (parameters, request bodies, response codes).
---
## API documentation (Swagger)
The full API is documented as an **OpenAPI 3.0** spec and served with **Swagger UI**:
| URL | What |
|---|---|
| `http://localhost:3000/api/docs` | Interactive Swagger UI (try-it-out, auth) |
| `http://localhost:3000/api/docs.json` | Raw OpenAPI 3.0 spec (JSON) |
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
groups) with its summary, parameters, request body, security requirement, and the response codes it
actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `429` rate limits, …).
**Authentication in the UI** — click **Authorize** and provide either:
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
`POST /api/v1/auth/login`), or
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
`Authorization: Bearer <token>`).
**Regenerating the spec** — the spec is generated from `#swagger.*` annotations next to each route
(`server/src/router/**`) plus the shared definitions in `server/swagger/swagger.js`
([swagger-autogen](https://github.com/davibaltar/swagger-autogen)). The output
`server/swagger/swagger-output.json` is committed so the docs work with no build step. After adding
or changing a route, regenerate it:
```bash
cd server
npm run swagger # → server/swagger/swagger-output.json
```
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash).
---

45
bot/.env.example Normal file
View File

@@ -0,0 +1,45 @@
# ─── UOMysticmoon Discord bot — local dev environment ───
# Copy to bot/.env for running `npm run dev` outside Docker.
# (In Docker, the root .env / docker-compose provides these instead.)
#
# NOTE: there is no Discord bot token here on purpose. The token is entered
# in the admin panel (Discord Bot page), stored encrypted in the main site's
# DB, and pushed to this process in-memory over the internal API. It is
# never read from an env var and never written to this process's disk.
PORT=4100
# Logging — written to BOTH the console and a log file (default <bot>/logs/bot.log).
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
FILE_LOG_LEVEL=debug # file verbosity
LOG_TO_FILE=true # set false for console-only
# LOG_DIR= # defaults to bot/logs
# LOG_FILE=bot.log
# Shared secret for the internal API between this bot and the main site
# (server/). MUST be byte-for-byte identical to BOT_INTERNAL_KEY in
# server/.env.example / the root .env.example — it is the only auth on both
# sides' /internal/* routes, so a mismatch silently breaks every server<->bot
# call with 401s. Generate one long random string and copy it to both places.
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
# Where this bot calls back to the main site to fetch its config on boot
# (GET .../internal/bot-config), so a restart self-reconnects without needing
# the admin panel to push config again. This targets the site's UNPUBLISHED
# internal port (INTERNAL_PORT, default 3001) — NOT the public 3000. See #33.
SITE_INTERNAL_URL=http://localhost:3001/internal/bot-config
# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any
# visitor's browser can fetch. Used by /wiki (search) and /announce
# (re-post an existing news item).
SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
# Database (Phase 2+) — same physical DB as the main site, but the bot only
# ever reads/writes its OWN tables (guild_config, mod_actions, warnings, and
# more in later phases). It never touches site tables (users, bot_config,
# etc.) directly. Point this at the same DB the server/ uses.
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=change-me-db-password

3
bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
logs/

16
bot/Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

24
bot/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "uomysticmoon-bot",
"version": "1.0.0",
"description": "Discord bot for the UOMysticmoon community server",
"private": true,
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"keywords": ["discord", "discord.js"],
"author": "whitlocktech",
"license": "ISC",
"dependencies": {
"discord.js": "^14.16.3",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"mariadb": "^3.3.1",
"node-cron": "^3.0.3"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}

12
bot/src/app.js Normal file
View File

@@ -0,0 +1,12 @@
const express = require('express')
const internalRouter = require('./internal/internal.routes')
const app = express()
app.use(express.json())
app.get('/health', (req, res) => res.json({ status: 'ok' }))
app.use('/internal', internalRouter)
module.exports = app

38
bot/src/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,38 @@
// Runs once at process start, before the internal Express server is
// considered ready. Fetches current config from the main site (token,
// guildId, enabled) and reconnects immediately if enabled — so a bot
// container restart (crash, `docker compose restart`, host reboot) self-heals
// without any admin-panel interaction. Node 20's built-in fetch is used; no
// extra HTTP client dependency needed for a single startup call.
const discordManager = require('./discord/discordManager')
const createLogger = require('./utils/logger')
const log = createLogger('bootstrap')
async function bootstrap() {
const siteUrl = process.env.SITE_INTERNAL_URL
const key = process.env.BOT_INTERNAL_KEY
if (!siteUrl || !key) {
log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config')
return
}
try {
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
if (!res.ok) {
log.error('boot-time config fetch failed', { status: res.status })
return
}
const config = await res.json()
if (config.enabled) {
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
await discordManager.start({ token: config.token, guildId: config.guildId })
} else {
log.info('boot-time config says disabled — staying disconnected')
}
} catch (err) {
log.error('boot-time config fetch errored', { message: err.message })
}
}
module.exports = bootstrap

41
bot/src/db.js Normal file
View File

@@ -0,0 +1,41 @@
// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) —
// mirrors server/src/utils/db.js. The bot never reads/writes any table it
// doesn't own; site-owned tables (users, bot_config, etc.) are reached only
// through the internal API, never directly. Schema for these tables lives in
// server/db/schema.sql (same physical database, ensured by the main server on
// boot) — there's no separate migration tool to justify a second database for
// a single-guild v1 bot.
const mariadb = require('mariadb')
const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'uomysticmoon',
connectionLimit: 5,
insertIdAsNumber: true,
bigIntAsNumber: true,
decimalAsNumber: true,
// The driver defaults to 'local' — silently serializing bound JS Date
// params using the HOST MACHINE's local offset instead of the DB session's
// timezone (discovered via temp_roles.expires_at coming back hours off in
// dev, CDT vs the container's UTC). 'auto' negotiates the actual session
// timezone so Date round-trips correctly regardless of host TZ.
timezone: 'auto',
})
async function query(sql, params) {
const conn = await pool.getConnection()
try {
return await conn.query(sql, params)
} finally {
conn.release()
}
}
async function close() {
await pool.end()
}
module.exports = { query, close }

View 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}` })
}
},
}

View 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 })
},
}

View 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 })
},
}

View 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 })
}
},
}

View 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 })
}
},
}

View 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),
}

View File

@@ -0,0 +1,85 @@
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
const guildConfig = require('../../model/guildConfig')
const inviteLog = require('../../model/inviteLog')
const inviteRotator = require('../../invites/inviteRotator')
module.exports = {
data: {
name: 'invite',
description: 'Manage the auto-rotating primary server invite.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'channel',
description: 'View or set the channel new invites are created in.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: 'channel',
description: 'Channel to create invites in. Omit to view the current setting.',
type: ApplicationCommandOptionType.Channel,
channel_types: [ChannelType.GuildText],
required: false,
},
],
},
{
name: 'rotate',
description: 'Revoke the current invite and generate a new one now.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
{
name: 'log',
description: 'Show recent invite rotation history.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
if (sub === 'channel') {
const channel = interaction.options.getChannel('channel')
if (!channel) {
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
return
}
if (sub === 'rotate') {
await interaction.deferReply({ ephemeral: true })
try {
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
triggeredBy: interaction.user.id,
triggeredByTag: interaction.user.tag,
})
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
} catch (err) {
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
}
return
}
if (sub === 'log') {
const rows = await inviteLog.list(interaction.guildId, 10)
if (rows.length === 0) {
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const who = r.triggered_by_tag || 'automatic (scheduled)'
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
},
}

View 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 })
},
}

View 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 })
},
}

View 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 })
},
}

View 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 })
},
}

View 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 })
},
}

View 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 })
}
},
}

View File

@@ -0,0 +1,85 @@
const {
PermissionFlagsBits,
ApplicationCommandOptionType,
ChannelType,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
} = require('discord.js')
const roleMenus = require('../../model/roleMenus')
// Capped at 5 roles per menu — a single Discord action row holds at most 5
// buttons, and one row keeps this a single simple slash command instead of
// needing a multi-step builder/modal flow.
const MAX_ROLES = 5
// role1/label1 are declared inline in `data` (ahead of the optional
// `description` option, per Discord's required-before-optional rule) — this
// generates the rest, all optional.
function roleOptions(from, to) {
const opts = []
for (let i = from; i <= to; i++) {
opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false })
opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false })
}
return opts
}
module.exports = {
data: {
name: 'rolemenu',
description: 'Post a button menu for self-assignable roles (up to 5).',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
// Discord requires all required options before any optional ones across
// the whole array — role1 (required) must come before description
// (optional), even though they read more naturally in the other order.
options: [
{ name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
{ name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true },
{ name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true },
{ name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false },
{ name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false },
...roleOptions(2, MAX_ROLES),
],
},
async execute(interaction) {
const channel = interaction.options.getChannel('channel', true)
const title = interaction.options.getString('title', true)
const description = interaction.options.getString('description') || undefined
const entries = []
for (let i = 1; i <= MAX_ROLES; i++) {
const role = interaction.options.getRole(`role${i}`)
if (!role) continue
const label = interaction.options.getString(`label${i}`) || role.name
entries.push({ roleId: role.id, label })
}
if (entries.length === 0) {
await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true })
return
}
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
if (description) embed.setDescription(description)
const row = new ActionRowBuilder().addComponents(
entries.map((e) =>
new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary),
),
)
const message = await channel.send({ embeds: [embed], components: [row] })
await roleMenus.add({
guildId: interaction.guildId,
channelId: channel.id,
messageId: message.id,
mapping: entries,
createdBy: interaction.user.id,
})
await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true })
},
}

View 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}.` })
}
},
}

View File

@@ -0,0 +1,119 @@
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
const cron = require('node-cron')
const scheduledMessages = require('../../model/scheduledMessages')
const scheduler = require('../../scheduler/scheduler')
const { parseDuration } = require('../../utils/duration')
module.exports = {
data: {
name: 'schedule',
description: 'Manage recurring and one-off scheduled channel messages.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'recurring',
description: 'Schedule a recurring message on a cron schedule.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
{ name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true },
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
],
},
{
name: 'once',
description: 'Schedule a one-off message for a future time.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
{ name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true },
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
],
},
{
name: 'remove',
description: 'Remove a scheduled message by id.',
type: ApplicationCommandOptionType.Subcommand,
options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }],
},
{
name: 'list',
description: 'List all scheduled messages.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
if (sub === 'recurring') {
const channel = interaction.options.getChannel('channel', true)
const cronExpr = interaction.options.getString('cron', true)
const message = interaction.options.getString('message', true)
if (!cron.validate(cronExpr)) {
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
return
}
const id = await scheduledMessages.addRecurring({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
cronExpression: cronExpr,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await scheduler.refresh()
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
return
}
if (sub === 'once') {
const channel = interaction.options.getChannel('channel', true)
const inInput = interaction.options.getString('in', true)
const message = interaction.options.getString('message', true)
const ms = parseDuration(inInput)
if (!ms) {
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
return
}
const runAt = new Date(Date.now() + ms)
const id = await scheduledMessages.addOnce({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
runAt,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
return
}
if (sub === 'remove') {
const id = interaction.options.getInteger('id', true)
const removed = await scheduledMessages.remove(interaction.guildId, id)
await scheduler.refresh()
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
return
}
if (sub === 'list') {
const rows = await scheduledMessages.list(interaction.guildId)
if (rows.length === 0) {
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const kind = r.cron_expression
? `cron \`${r.cron_expression}\``
: r.sent_at
? `sent ${new Date(r.sent_at).toLocaleString()}`
: `due ${new Date(r.run_at).toLocaleString()}`
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
},
}

View 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 })
},
}

View 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 })
},
}

View 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 })
},
}

View File

@@ -0,0 +1,144 @@
// Owns the single discord.js Client instance for this process: lifecycle
// (start/stop/status) and slash-command registration/dispatch. Command
// definitions themselves live in ./commands — this file only wires them up.
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
const createLogger = require('../utils/logger')
const commands = require('./commands')
const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler')
const { handleGuildMemberAdd } = require('./guildMemberAdd')
const { handleGuildMemberRemove } = require('./guildMemberRemove')
const inviteTracker = require('./inviteTracker')
const tempRoleSweeper = require('../roles/tempRoleSweeper')
const inviteScheduler = require('../invites/inviteScheduler')
const log = createLogger('discord')
let client = null
let guildId = null
let status = 'disconnected' // disconnected | connecting | connected | error
let statusDetail = null
let lastConnectedAt = null
async function registerCommands(applicationId, targetGuildId) {
const rest = new REST({ version: '10' }).setToken(client.token)
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
body: commands.all.map((c) => c.data),
})
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
}
async function stop() {
if (!client) {
status = 'disconnected'
statusDetail = null
return
}
scheduler.stop()
tempRoleSweeper.stop()
inviteScheduler.stop()
try {
await client.destroy()
} catch (err) {
log.warn('error while destroying client', { message: err.message })
}
client = null
status = 'disconnected'
statusDetail = null
log.info('discord client disconnected')
}
// start({ token, guildId }) — (re)connects. Always stops any existing client
// first so re-saving config or toggling Enabled off/on is idempotent.
async function start({ token, guildId: gid }) {
await stop()
guildId = gid
status = 'connecting'
statusDetail = null
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
// in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildInvites,
],
})
client.once('ready', async () => {
try {
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected'
statusDetail = null
lastConnectedAt = new Date()
log.info('discord client ready', { user: client.user?.tag, guildId })
} catch (err) {
status = 'error'
statusDetail = `startup failed: ${err.message}`
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
}
})
client.on('interactionCreate', async (interaction) => {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) return
try {
await command.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
else await interaction.reply(payload)
}
})
client.on('messageCreate', messageFilter.handleMessageCreate)
client.on('guildMemberAdd', handleGuildMemberAdd)
client.on('guildMemberRemove', handleGuildMemberRemove)
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
client.on('inviteCreate', inviteTracker.onInviteCreate)
client.on('inviteDelete', inviteTracker.onInviteDelete)
client.on('error', (err) => {
status = 'error'
statusDetail = err.message
log.error('discord client error', { message: err.message })
})
try {
await client.login(token)
} catch (err) {
status = 'error'
statusDetail = err.message
client = null
log.error('discord login failed', { message: err.message })
throw err
}
}
function getStatus() {
return { status, statusDetail, guildId, lastConnectedAt }
}
// For code that needs the live client + which guild it's connected to (the
// /internal/announce handler, slash commands already get both from the
// interaction itself so they don't need this). Returns null if disconnected.
function getConnection() {
if (!client || status !== 'connected') return null
return { client, guildId }
}
module.exports = { start, stop, getStatus, getConnection }

View 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 }

View 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 }

View 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 }

View File

@@ -0,0 +1,137 @@
// messageCreate orchestration: allowlist bypass -> invite link -> banned word
// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn
// (no severity tiers for those, unlike the word filter) — kept simple per the
// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute
// duration; per-severity-configurable durations are a future refinement.
const filterCache = require('../filter/filterCache')
const { findMatch } = require('../filter/normalize')
const inviteFilter = require('../filter/inviteFilter')
const spamFilter = require('../filter/spamFilter')
const warnings = require('../model/warnings')
const filterHits = require('../model/filterHits')
const spamHits = require('../model/spamHits')
const modLog = require('./modLog')
const createLogger = require('../utils/logger')
const log = createLogger('filter')
const FILTER_MUTE_SECONDS = 600 // 10 minutes
function botActor(client) {
return { id: client.user.id, tag: client.user.tag }
}
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
// break the moderation action it accompanies, so failures are swallowed+logged.
async function recordFilterHit(message, hitType, matched, actionTaken) {
try {
await filterHits.record({
guildId: message.guildId,
hitType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
matched,
actionTaken,
})
} catch (err) {
log.warn('filter hit record failed', { message: err.message })
}
}
async function recordSpamHit(message, spamType) {
try {
await spamHits.record({
guildId: message.guildId,
spamType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
})
} catch (err) {
log.warn('spam hit record failed', { message: err.message })
}
}
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
// effect (records this message's timestamp) so it must be evaluated first, and
// exactly once — mirroring the original OR-order.
function detectSpam(message) {
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
if (spamFilter.isMassMention(message)) return 'mass_mention'
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
return null
}
async function isBypassed(message, cache) {
if (cache.allowChannels.has(message.channelId)) return true
const memberRoles = message.member ? message.member.roles.cache : null
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true
return false
}
async function applyWarnAction(message, reason) {
const staff = botActor(message.client)
await warnings.add({
guildId: message.guildId,
targetUserId: message.author.id,
targetTag: message.author.tag,
staffUserId: staff.id,
staffTag: staff.tag,
reason,
})
await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason })
}
async function applyMuteAction(message, reason) {
const staff = botActor(message.client)
if (message.member && message.member.moderatable) {
await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason)
}
await modLog.record({
client: message.client,
guildId: message.guildId,
actionType: 'mute',
target: message.author,
staffUser: staff,
reason,
durationSeconds: FILTER_MUTE_SECONDS,
})
}
async function handleMessageCreate(message) {
if (message.author.bot || !message.guildId) return
try {
const cache = await filterCache.getOrLoad(message.guildId)
if (await isBypassed(message, cache)) return
const foreignCode = await inviteFilter.foreignInviteCode(message)
if (foreignCode) {
await message.delete().catch(() => {})
await recordFilterHit(message, 'invite', foreignCode, 'warn')
await applyWarnAction(message, 'Posted a Discord invite link')
return
}
const match = findMatch(message.content, cache.words)
if (match) {
await message.delete().catch(() => {})
await recordFilterHit(message, 'word', match.word, match.severity)
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
return
}
const spamType = detectSpam(message)
if (spamType) {
await message.delete().catch(() => {})
await recordSpamHit(message, spamType)
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
}
} catch (err) {
log.error('messageFilter failed', { message: err.message })
}
}
module.exports = { handleMessageCreate }

53
bot/src/discord/modLog.js Normal file
View File

@@ -0,0 +1,53 @@
// Shared by every moderation command (ban/kick/mute/warn): writes the audit
// row and posts the embed to the configured mod-log channel. Takes `client`
// as a parameter (from interaction.client) rather than importing
// discordManager directly, to avoid a require cycle (discordManager -> commands
// -> modLog -> discordManager).
const { EmbedBuilder } = require('discord.js')
const db = require('../db')
const guildConfig = require('../model/guildConfig')
const createLogger = require('../utils/logger')
const log = createLogger('modlog')
const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 }
async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) {
await db.query(
`INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null],
)
try {
const channelId = await guildConfig.getModLogChannelId(guildId)
if (!channelId) return
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) return
const embed = new EmbedBuilder()
.setColor(COLOR[actionType] || 0x9aa5b1)
.setTitle(actionType.toUpperCase())
.addFields(
{ name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true },
{ name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true },
)
.setTimestamp()
if (reason) embed.addFields({ name: 'Reason', value: reason })
if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true })
await channel.send({ embeds: [embed] })
} catch (err) {
log.warn('failed to post mod-log embed', { message: err.message })
}
}
function formatDuration(seconds) {
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
module.exports = { record }

View File

@@ -0,0 +1,26 @@
// Shared by the /internal/announce webhook (site publishes a news post) and
// the manual /announce command (staff re-posts/boosts an existing one) — so
// both paths produce an identical embed.
const { EmbedBuilder } = require('discord.js')
const guildConfig = require('../model/guildConfig')
const createLogger = require('../utils/logger')
const log = createLogger('news')
async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) {
const channelId = await guildConfig.getNewsChannelId(guildId)
if (!channelId) throw new Error('No news channel configured — set one with /news first.')
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url)
if (excerpt) embed.setDescription(excerpt)
if (imageUrl) embed.setImage(imageUrl)
await channel.send({ embeds: [embed] })
log.info('news announced', { title, channelId })
}
module.exports = { postAnnounce }

View 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 }

View 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 }

View File

@@ -0,0 +1,26 @@
// Detects Discord invite links and blocks any that don't resolve to the
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
// (expired/invalid/vanity-only) is treated as foreign too — safer default
// than silently letting an unresolvable link through.
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
// Returns the first foreign (or unresolvable) invite code found in the message,
// or null if the message contains no foreign invites. Returning the code (rather
// than a bare boolean) lets the caller record which invite was blocked.
async function foreignInviteCode(message) {
const matches = [...message.content.matchAll(INVITE_REGEX)]
if (matches.length === 0) return null
for (const match of matches) {
const code = match[1]
try {
const invite = await message.client.fetchInvite(code)
if (invite.guild?.id !== message.guildId) return code
} catch {
return code
}
}
return null
}
module.exports = { foreignInviteCode }

View 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 }

View 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 }

View File

@@ -0,0 +1,50 @@
const discordManager = require('../discord/discordManager')
const newsAnnounce = require('../discord/newsAnnounce')
const createLogger = require('../utils/logger')
const log = createLogger('internal')
// POST /internal/config — called by the main server right after an admin
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
// (via a GET to the server for the current config, then this same start/stop
// logic locally). Body: { token, guildId, enabled }.
async function setConfig(req, res) {
const { token, guildId, enabled } = req.body || {}
try {
if (enabled) {
if (!token || !guildId) {
return res.status(400).json({ message: 'token and guildId are required when enabled' })
}
await discordManager.start({ token, guildId })
} else {
await discordManager.stop()
}
return res.json(discordManager.getStatus())
} catch (err) {
log.error('setConfig failed', { message: err.message })
// Still 200 with an error status — the caller (admin panel) should surface
// discordManager's status/statusDetail rather than treat this as a 5xx.
return res.json(discordManager.getStatus())
}
}
// GET /internal/status — live connection state, polled by the admin panel.
function getStatusHandler(req, res) {
return res.json(discordManager.getStatus())
}
// POST /internal/announce — called by the main server right after a news
// post is published. Body: { title, excerpt, url, imageUrl }.
async function announce(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
try {
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
return res.json({ posted: true })
} catch (err) {
log.warn('announce failed', { message: err.message })
return res.status(400).json({ message: err.message })
}
}
module.exports = { setConfig, getStatus: getStatusHandler, announce }

View File

@@ -0,0 +1,14 @@
const express = require('express')
const requireInternalKey = require('./requireInternalKey')
const ctrl = require('./internal.controller')
const router = express.Router()
router.use(requireInternalKey)
router.post('/config', ctrl.setConfig)
router.get('/status', ctrl.getStatus)
router.post('/announce', ctrl.announce)
module.exports = router

View File

@@ -0,0 +1,19 @@
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
// server, over the private compose network — never expose this route through
// the public reverse proxy. Timing-safe compare so response time can't be used
// to brute-force the shared secret one byte at a time.
const crypto = require('crypto')
function requireInternalKey(req, res, next) {
const expected = process.env.BOT_INTERNAL_KEY || ''
const provided = req.get('X-Internal-Key') || ''
const a = Buffer.from(expected)
const b = Buffer.from(provided)
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
if (!match) return res.status(401).json({ message: 'Unauthorized' })
return next()
}
module.exports = requireInternalKey

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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
View 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 }

View 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
View 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 }

View 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 }

View File

@@ -0,0 +1,83 @@
// Recurring + one-off scheduled channel messages. Recurring rows are each
// registered as their own node-cron task; one-off rows are picked up by a
// once-a-minute sweep that checks for anything due and marks it sent so it
// never reposts. Needs a live discord.js Client to actually send — wired up
// by discordManager.js (start() once the client is ready, stop() alongside
// client teardown).
const cron = require('node-cron')
const scheduledMessages = require('../model/scheduledMessages')
const createLogger = require('../utils/logger')
const log = createLogger('scheduler')
let discordClient = null
const recurringTasks = new Map() // id -> node-cron ScheduledTask
let sweepTask = null
async function sendToChannel(channelId, content) {
try {
const channel = await discordClient.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) {
log.warn('scheduled message skipped — channel missing or not text-based', { channelId })
return
}
await channel.send({ content })
log.info('sent scheduled message', { channelId })
} catch (err) {
log.warn('failed to send scheduled message', { channelId, message: err.message })
}
}
async function loadRecurring() {
for (const task of recurringTasks.values()) task.stop()
recurringTasks.clear()
const rows = await scheduledMessages.listEnabledRecurring()
for (const row of rows) {
if (!cron.validate(row.cron_expression)) {
log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression })
continue
}
const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content))
recurringTasks.set(row.id, task)
}
log.info('loaded recurring scheduled messages', { count: recurringTasks.size })
}
async function sweepDueOneOff() {
try {
const due = await scheduledMessages.listDueOneOff()
for (const row of due) {
await sendToChannel(row.channel_id, row.content)
await scheduledMessages.markSent(row.id)
}
} catch (err) {
log.error('one-off sweep failed', { message: err.message })
}
}
async function start(client) {
discordClient = client
await loadRecurring()
sweepTask = cron.schedule('* * * * *', sweepDueOneOff)
log.info('scheduler started')
}
// Called by /schedule after any add/remove so changes apply without a restart.
async function refresh() {
if (!discordClient) return
await loadRecurring()
}
function stop() {
for (const task of recurringTasks.values()) task.stop()
recurringTasks.clear()
if (sweepTask) {
sweepTask.stop()
sweepTask = null
}
discordClient = null
}
module.exports = { start, stop, refresh }

52
bot/src/server.js Normal file
View File

@@ -0,0 +1,52 @@
require('dotenv').config()
const app = require('./app')
const bootstrap = require('./bootstrap')
const createLogger = require('./utils/logger')
const discordManager = require('./discord/discordManager')
const pkg = require('../package.json')
const log = createLogger('server')
const PORT = Number(process.env.PORT) || 4100
const HOST = '0.0.0.0'
async function start() {
log.info(`starting UOMysticmoon bot v${pkg.version}`, {
node: process.version,
logFile: createLogger.logFilePath || 'disabled (console only)',
})
const server = app.listen(PORT, HOST, () => {
log.info(`internal API listening on http://${HOST}:${PORT}`)
})
await bootstrap()
setupShutdown(server)
}
function setupShutdown(server) {
let closing = false
const shutdown = async (signal) => {
if (closing) return
closing = true
log.warn(`${signal} received — shutting down gracefully`)
server.close(() => log.info('internal API closed'))
await discordManager.stop()
await createLogger.close()
process.exit(0)
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
process.exit(1)
})
}
start().catch((err) => {
log.error('failed to start bot', err)
process.exit(1)
})

View 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
View File

@@ -0,0 +1,16 @@
// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds.
// Returns null for anything unparseable. Discord's own timeout API caps at 28
// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input.
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
const MAX_TIMEOUT_MS = 28 * 86_400_000
function parseDuration(input) {
if (!input) return null
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim())
if (!match) return null
const [, amount, unit] = match
return Number(amount) * UNIT_MS[unit.toLowerCase()]
}
module.exports = { parseDuration, MAX_TIMEOUT_MS }

97
bot/src/utils/logger.js Normal file
View 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

View File

@@ -3,6 +3,7 @@ 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 RoleGate from './components/RoleGate.jsx'
// Public
import Portal from './routes/public/Portal.jsx'
@@ -27,9 +28,12 @@ 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 AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
export default function App() {
return (
@@ -72,8 +76,20 @@ export default function App() {
<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>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="account" element={<AccountAdmin />} />

View File

@@ -46,6 +46,9 @@ export const api = {
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }.
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'),
@@ -117,6 +120,52 @@ export const api = {
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
},
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
modMembers: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
},
modFilterHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
},
modSpamHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
},
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
modUserActions: (discordId, params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
},
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
addModNote: (discordId, data) =>
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
@@ -132,6 +181,10 @@ export const api = {
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 }),
},
}

View File

@@ -0,0 +1,11 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
// Client-side role gate for admin sub-sections. Real enforcement is server-side
// (requireRole); this just keeps the UI honest — a user without one of `roles`
// is redirected rather than shown a page that will only 403 on every call.
export default function RoleGate({ roles, children, redirect = '/admin' }) {
const { user } = useAuth()
if (user && !roles.includes(user.role)) return <Navigate to={redirect} replace />
return children
}

View File

@@ -37,6 +37,14 @@ export function AuthProvider({ children }) {
return data.user
}, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
const ssoLoginTotp = useCallback(async (code) => {
const data = await api.ssoLoginTotp(code)
setUser(data.user)
return data
}, [])
const logout = useCallback(async () => {
try {
await api.logout()
@@ -46,7 +54,7 @@ export function AuthProvider({ children }) {
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
{children}
</AuthContext.Provider>
)

View File

@@ -84,6 +84,25 @@ export function defaultLayout(teaser) {
],
},
},
{
id: 'default-quick-links',
type: 'buttons',
x: 50,
y: 85,
z: 3,
anchor: 'center',
props: {
align: 'center',
gap: 10,
items: [
{ label: 'News', to: '/site/news', variant: 'ghost' },
{ label: 'Screenshots', to: '/site/screenshots', variant: 'ghost' },
{ label: 'Five on Friday', to: '/site/five-on-friday', variant: 'ghost' },
{ label: 'Monthly Newsletter', to: '/site/newsletter', variant: 'ghost' },
{ label: 'About', to: '/site/about', variant: 'ghost' },
],
},
},
],
}
}

View File

@@ -4,14 +4,19 @@ import MoonDot from '../../components/MoonDot.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
// `roles` (when present) restricts which roles see a nav item. Items without it
// are shown to admin/editor as before. Moderators are further confined to just
// their own section + account security (see the redirect effect below).
const NAV = [
{ to: '/admin', label: 'Dashboard', end: true },
{ to: '/admin/posts', label: 'Posts' },
{ to: '/admin/wiki', label: 'Wiki' },
{ to: '/admin/hero', label: 'Hero Editor' },
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
{ to: '/admin/settings', label: 'Settings' },
{ to: '/admin/activity', label: 'Activity' },
{ to: '/admin/bot-activity', label: 'Bot Activity' },
{ to: '/admin/discord-bot', label: 'Discord Bot' },
{ to: '/admin/auth-providers', label: 'Authentication' },
{ to: '/admin/users', label: 'Users' },
{ to: '/admin/account', label: 'Account' },
@@ -22,9 +27,11 @@ const TITLES = {
'/admin/posts': 'Posts',
'/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/account': 'Account Security',
@@ -46,11 +53,31 @@ export default function AdminLayout() {
const { mode } = useSite()
const navigate = useNavigate()
const location = useLocation()
const title = TITLES[location.pathname] || 'Admin'
const title =
TITLES[location.pathname] ||
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
// The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Moderators only get the moderation section + their own account security.
const isModerator = user?.role === 'moderator'
const navItems = NAV.filter((n) => {
if (n.roles && !n.roles.includes(user?.role)) return false
if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account'
return true
})
// Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => {
if (!isModerator) return
const p = location.pathname
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => {
const meta = document.createElement('meta')
@@ -92,7 +119,7 @@ export default function AdminLayout() {
</div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{NAV.map((n) => (
{navItems.map((n) => (
<NavLink
key={n.to}
to={n.to}

View File

@@ -31,7 +31,7 @@ const honeypotStyle = {
}
export default function AdminLogin() {
const { user, login, loginTotp } = useAuth()
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/admin'
@@ -42,10 +42,12 @@ export default function AdminLogin() {
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Two-factor step state.
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
// SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...).
@@ -57,6 +59,16 @@ export default function AdminLogin() {
if (user) navigate(dest, { replace: true })
}, [user, dest, navigate])
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
// before it will issue a session. Jump straight to the code step.
useEffect(() => {
if (new URLSearchParams(location.search).get('sso_totp')) {
setStage('totp')
setSsoTotp(true)
}
}, [location.search])
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
// still works with password login and simply shows no provider buttons.
useEffect(() => {
@@ -101,16 +113,25 @@ export default function AdminLogin() {
setError('')
setBusy(true)
try {
if (ssoTotp) {
const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/admin', { replace: true })
} else {
await loginTotp(challenge, code)
navigate(dest, { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
setError(
err.status === 401 && /expired/i.test(err.message)
expired
? 'Your verification session expired. Please sign in again.'
: 'Invalid verification code.',
)
setBusy(false)
if (err.status === 401 && /expired/i.test(err.message)) setStage('creds')
if (expired) {
setStage('creds')
setSsoTotp(false)
}
}
}

View File

@@ -0,0 +1,151 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Discord bot control panel (Phase 1). The bot token is write-only over this
// API — stored encrypted in the DB, never returned — same convention as the
// Google/Discord login-SSO secrets on the Authentication page. Saving pushes
// the config straight to the bot process, so Enabled takes effect immediately
// with no redeploy.
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
const STATUS_COLOR = {
connected: '#7fd0a4',
connecting: '#e0b070',
error: '#d98b84',
disconnected: 'var(--muted)',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'disconnected'}
</span>
</div>
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
{config.lastConnectedAt && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Last connected: {new Date(config.lastConnectedAt).toLocaleString()}
</p>
)}
</div>
)
}
export default function DiscordBotAdmin() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [guildId, setGuildId] = useState('')
const [token, setToken] = useState('')
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [saveError, setSaveError] = useState('')
const pollRef = useRef(null)
// Only the very first load seeds the editable fields (guildId/enabled).
// Every subsequent poll tick updates `config` (status/hasToken/etc.) so the
// live-status panel stays fresh, but must NOT touch the form state — doing
// so would silently overwrite whatever the admin is mid-typing/toggling
// before they get a chance to hit Save.
const initializedRef = useRef(false)
const load = useCallback(async () => {
try {
const c = await api.admin.getDiscordBotConfig()
setConfig(c)
if (!initializedRef.current) {
setGuildId(c.guildId || '')
setEnabled(c.enabled)
initializedRef.current = true
}
} catch {
setError('Could not load Discord bot config.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 5000)
return () => clearInterval(pollRef.current)
}, [load])
async function save() {
setBusy(true)
setMsg('')
setSaveError('')
try {
const body = { guildId, enabled }
if (token) body.token = token // only send a new token when entered
const saved = await api.admin.saveDiscordBotConfig(body)
setConfig(saved)
setToken('')
setMsg('Saved.')
} catch (err) {
setSaveError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!config) return <Loading />
return (
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Discord Bot
</h2>
<StatusPanel config={config} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the bot" />
<label style={{ display: 'block' }}>
<span className="field-label">Guild (server) ID</span>
<input
type="text"
value={guildId}
onChange={(e) => setGuildId(e.target.value)}
className="input"
autoComplete="off"
placeholder="123456789012345678"
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Bot Token</span>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
className="input"
autoComplete="new-password"
placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Bot token'}
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save changes'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
</div>
</section>
)
}

View File

@@ -39,6 +39,20 @@ function newElement(type, z) {
const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
// Scale a text line's font size by a ratio when its box is resized, so the corner
// handle acts as a WYSIWYG zoom that keeps the h1/h2/p ratios intact. Numeric px
// sizes (editor-authored) and simple rem/em/px strings scale; responsive strings
// like clamp()/vw are left alone so they keep adapting to the viewport.
const FONT_UNIT_RE = /^(\d*\.?\d+)(rem|em|px)$/
function scaleFontSize(v, ratio) {
if (typeof v === 'number') return Math.max(6, Math.round(v * ratio))
if (typeof v === 'string') {
const m = FONT_UNIT_RE.exec(v.trim())
if (m) return `${round2(parseFloat(m[1]) * ratio)}${m[2]}`
}
return v
}
export default function HeroEditor() {
const [layout, setLayout] = useState(null)
const [live, setLive] = useState(null)
@@ -190,6 +204,10 @@ export default function HeroEditor() {
const rect = canvasRef.current.getBoundingClientRect()
const sx = e.clientX
const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64)
// Snapshot the starting width + lines for text blocks so font scaling is always
// computed against the drag origin (no rounding drift as the pointer moves).
const baseWidth = el.type === 'text_block' ? orig : 0
const baseLines = el.type === 'text_block' ? el.props?.lines || [] : null
const node = e.currentTarget
try {
node.setPointerCapture(e.pointerId)
@@ -199,11 +217,17 @@ export default function HeroEditor() {
const move = (ev) => {
const dxPx = ev.clientX - sx
const dxLogical = dxPx / scale // client px → stage px
let val
if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // %
else if (el.type === 'moon') val = clamp(orig + dxLogical, 24, 400) // px
else val = clamp(orig + dxLogical, 120, 1180) // text_block box px
updateProps(el.id, { [dim]: Math.round(val) })
if (el.type === 'image') {
updateProps(el.id, { width: Math.round(clamp(orig + (dxPx / rect.width) * 100, 5, 100)) }) // %
} else if (el.type === 'moon') {
updateProps(el.id, { size: Math.round(clamp(orig + dxLogical, 24, 400)) }) // px
} else {
// text_block: resize the box and scale every line's font proportionally.
const width = Math.round(clamp(orig + dxLogical, 120, 1180))
const ratio = baseWidth ? width / baseWidth : 1
const lines = baseLines.map((l) => ({ ...l, fontSize: scaleFontSize(l.fontSize, ratio) }))
updateProps(el.id, { width, lines })
}
}
const up = () => {
node.removeEventListener('pointermove', move)
@@ -324,7 +348,7 @@ export default function HeroEditor() {
</div>
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
Click to select · drag to move · Delete key removes the selected element.
Click to select · drag to move · drag the corner handle to resize (text scales with the box) · Delete key removes the selected element.
</p>
</div>

View File

@@ -0,0 +1,330 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
const WINDOWS = [
{ key: '24h', label: 'Last 24h' },
{ key: '7d', label: 'Last 7 days' },
{ key: '30d', label: 'Last 30 days' },
]
const TYPES = [
{ key: null, label: 'All' },
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
const MOD_TILES = [
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
// Second tile row → jumps the events panel to the matching stream.
const EVENT_TILES = [
{ key: 'joins', label: 'Joins', tab: 'members' },
{ key: 'leaves', label: 'Leaves', tab: 'members' },
{ key: 'filter_hits', label: 'Filter hits', tab: 'filter' },
{ key: 'spam_hits', label: 'Spam hits', tab: 'spam' },
]
const EVENT_TABS = [
{ key: 'members', label: 'Members' },
{ key: 'filter', label: 'Filter hits' },
{ key: 'spam', label: 'Spam hits' },
]
export default function Moderation() {
const navigate = useNavigate()
const [win, setWin] = useState('24h')
const [typeFilter, setTypeFilter] = useState(null)
const [eventTab, setEventTab] = useState('members')
const { loading, error, data } = useAsync(
() =>
Promise.all([
api.admin.modSummary(),
api.admin.modRecent({ limit: 100 }),
api.admin.modMembers({ limit: 50 }),
api.admin.modFilterHits({ limit: 50 }),
api.admin.modSpamHits({ limit: 50 }),
]),
[],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load moderation data." />
const [summary, recent, members, filterHits, spamHits] = data
const counts = summary.windows?.[win] || {}
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
return (
<section>
<UserSearch onPick={goUser} />
{/* Window selector */}
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
{WINDOWS.map((w) => (
<button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
{w.label}
</button>
))}
</div>
{/* Moderation-action tiles (click filters the recent-actions feed) */}
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
{MOD_TILES.map((t) => (
<Tile
key={t.key}
value={counts[t.key] ?? 0}
label={t.label}
active={typeFilter === t.key}
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
/>
))}
</div>
{/* Event tiles (click jumps the events panel to that stream) */}
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
{EVENT_TILES.map((t) => (
<Tile
key={t.key}
value={counts[t.key] ?? 0}
label={t.label}
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
active={eventTab === t.tab}
onClick={() => setEventTab(t.tab)}
/>
))}
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
</p>
{/* Recent moderation actions */}
<div style={rowHead}>
<h2 className="display" style={h2}>Recent actions</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{TYPES.map((t) => (
<button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
{t.label}
</button>
))}
</div>
</div>
<div className="panel-flat" style={{ marginBottom: 30 }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Action</th>
<th className="adm-th">Target</th>
<th className="adm-th">Staff</th>
<th className="adm-th">Reason</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{feed.length === 0 && (
<tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
)}
{feed.map((a) => (
<tr key={a.id}>
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
<td className="adm-td">
<span className="link-accent" onClick={() => goUser(a.target_user_id)}>{a.target_tag || a.target_user_id}</span>
{a.linked_account && <span className="badge badge-editor" style={{ marginLeft: 8 }}>site: {a.linked_account.username}</span>}
</td>
<td className="adm-td">
{a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>}
</td>
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>{a.reason || '—'}</td>
<td className="adm-td dim" title={dateTime(a.created_at)}>{ago(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Event streams panel */}
<div style={rowHead}>
<h2 className="display" style={h2}>Events</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{EVENT_TABS.map((t) => (
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
{t.label}
</button>
))}
</div>
</div>
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
</section>
)
}
function Tile({ value, label, sub, active, onClick }) {
return (
<button
onClick={onClick}
style={{
textAlign: 'left',
padding: 20,
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
borderRadius: 12,
background: 'var(--panel-grad)',
cursor: 'pointer',
}}
>
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
</button>
)
}
function MembersTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Event</th>
<th className="adm-th">User</th>
<th className="adm-th">Invite</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
{rows.map((m) => (
<tr key={m.id}>
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
<td className="adm-td dim">
{m.invite_code ? (
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
) : '—'}
</td>
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function FilterTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Type</th>
<th className="adm-th">User</th>
<th className="adm-th">Matched</th>
<th className="adm-th">Action</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
{rows.map((f) => (
<tr key={f.id}>
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
function SpamTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Type</th>
<th className="adm-th">User</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
{rows.map((s) => (
<tr key={s.id}>
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// User lookup: search by Discord id or a historical username snapshot.
function UserSearch({ onPick }) {
const [term, setTerm] = useState('')
const [results, setResults] = useState(null)
const [busy, setBusy] = useState(false)
async function run(e) {
e.preventDefault()
const q = term.trim()
if (!q) return
setBusy(true)
try {
setResults(await api.admin.modSearch(q))
} finally {
setBusy(false)
}
}
return (
<div style={{ marginBottom: 22 }}>
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
<input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
</form>
{results && results.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match {term}.</p>
)}
{results && results.length > 0 && (
<div className="panel-flat" style={{ marginTop: 10 }}>
<table className="adm-table">
<tbody>
{results.map((r) => (
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
<td className="adm-td dim">last {ago(r.last_seen)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
const muted = { color: 'var(--muted)' }

View File

@@ -0,0 +1,245 @@
import { useCallback, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime, ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
const ACTION_TABS = [
{ key: 'warn', label: 'Warnings' },
{ key: 'mute', label: 'Mutes' },
{ key: 'kick', label: 'Kicks' },
{ key: 'ban', label: 'Bans' },
]
function fmtDuration(seconds) {
if (!seconds) return null
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`
}
export default function ModerationUser() {
const { discordId } = useParams()
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const [tab, setTab] = useState('warn')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(
() =>
Promise.all([
api.admin.modUser(discordId),
api.admin.modUserActions(discordId, { limit: 200 }),
api.admin.modUserNotes(discordId),
]),
[discordId, tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this users history." />
const [summary, actions, notes] = data
const counts = summary.counts || {}
const tabActions = actions.filter((a) => a.action_type === tab)
return (
<section>
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to moderation
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 20px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{summary.tag || '(unknown user)'}
</span>
{summary.linked_account && (
<span className="badge badge-editor">site account: {summary.linked_account.username}</span>
)}
</div>
<div className="sans dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem', marginTop: 4 }}>
{discordId}
</div>
<div style={{ display: 'flex', gap: 18, marginTop: 14, flexWrap: 'wrap' }}>
{ACTION_TABS.map((t) => (
<Count key={t.key} label={t.label} value={counts[t.key] || 0} />
))}
<Count label="Notes" value={summary.notes_count || 0} />
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, borderBottom: '1px solid var(--line-soft)', paddingBottom: 12 }}>
{ACTION_TABS.map((t) => (
<TabButton key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label} ({counts[t.key] || 0})
</TabButton>
))}
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
Notes ({summary.notes_count || 0})
</TabButton>
</div>
{tab === 'notes' ? (
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
) : (
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
)}
</section>
)
}
function Count({ label, value }) {
return (
<div>
<div className="display" style={{ fontSize: '1.4rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
<div className="card-kicker" style={{ marginTop: 4, marginBottom: 0 }}>{label}</div>
</div>
)
}
function TabButton({ active, onClick, children }) {
return (
<button
onClick={onClick}
className="sans"
style={{
border: '1px solid var(--line)',
borderRadius: 8,
padding: '7px 14px',
cursor: 'pointer',
fontSize: '0.85rem',
background: active ? 'var(--blue)' : 'transparent',
color: active ? 'var(--ink)' : 'var(--muted)',
borderColor: active ? 'var(--accent)' : 'var(--line)',
}}
>
{children}
</button>
)
}
function ActionTable({ rows, showDuration }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reason</th>
<th className="adm-th">Actor</th>
{showDuration && <th className="adm-th">Duration</th>}
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={showDuration ? 4 : 3} style={{ color: 'var(--muted)' }}>
Nothing here.
</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{a.reason || '—'}</td>
<td className="adm-td">
{a.is_automated ? (
<span className="badge badge-auto">Automated</span>
) : (
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
)}
</td>
{showDuration && <td className="adm-td dim">{fmtDuration(a.duration_seconds) || '—'}</td>}
<td className="adm-td dim" title={dateTime(a.created_at)}>{dateTime(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('staff_only')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function add() {
if (!body.trim()) return
setBusy(true)
setErr('')
try {
await api.admin.addModNote(discordId, { body: body.trim(), visibility })
setBody('')
setVisibility('staff_only')
onAdded()
} catch (e) {
setErr(e.message || 'Could not save the note.')
} finally {
setBusy(false)
}
}
return (
<div>
<div style={{ marginBottom: 18 }}>
{err && <p className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{err}</p>}
<textarea
className="textarea"
placeholder="Add a staff note about this user…"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
<select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}>
<option value="staff_only">Staff only</option>
{isAdmin && <option value="admin_only">Admin only</option>}
</select>
<button onClick={add} disabled={busy || !body.trim()} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Add note'}
</button>
</div>
</div>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Note</th>
<th className="adm-th">Author</th>
<th className="adm-th">Visibility</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{notes.length === 0 && (
<tr>
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>No notes yet.</td>
</tr>
)}
{notes.map((n) => (
<tr key={n.id}>
<td className="adm-td" style={{ color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{n.body}</td>
<td className="adm-td dim">{n.author_username || n.author_tag || '—'}</td>
<td className="adm-td">
<span className={`badge ${n.visibility === 'admin_only' ? 'badge-ban' : 'badge-editor'}`}>
{n.visibility === 'admin_only' ? 'admin only' : 'staff'}
</span>
</td>
<td className="adm-td dim" title={dateTime(n.created_at)}>{ago(n.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -83,6 +83,7 @@ export default function UserEditor({ user, onClose, onSaved }) {
<select value={form.role} onChange={set('role')} className="select">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="moderator">moderator</option>
</select>
</label>
</div>

View File

@@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import UserEditor from './UserEditor.jsx'
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
export default function UsersAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
@@ -16,7 +18,7 @@ export default function UsersAdmin() {
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Manage admin and editor accounts
Manage admin, editor, and moderator accounts
</p>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ Add user
@@ -44,7 +46,7 @@ export default function UsersAdmin() {
{u.username}
</td>
<td className="adm-td">
<span className={`badge ${u.role === 'admin' ? 'badge-admin' : 'badge-editor'}`}>{u.role}</span>
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
</td>
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>

View File

@@ -1,34 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import HeroElement from '../../components/HeroElement.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
const QUICK = [
{ label: 'News', to: '/site/news' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Monthly Newsletter', to: '/site/newsletter' },
{ label: 'About', to: '/site/about' },
]
const DESTINATIONS = [
{
kicker: 'Public portal',
title: 'Mysticmoon Website',
body: 'Updates, screenshots, newsletters, and weekly community posts from the shard.',
to: '/site',
},
{
kicker: 'Knowledge base',
title: 'Mysticmoon Wiki',
body: 'Guides, maps, systems, items, monsters, crafting, lore, and rules.',
to: '/wiki',
},
]
// Admin "Preview" opens the portal with ?preview=1 to render the unpublished draft.
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
@@ -77,8 +53,8 @@ export default function Portal() {
<section
style={{
position: 'relative',
minHeight: 'clamp(600px,72vh,860px)',
overflow: 'hidden',
flex: 1,
minHeight: '100vh',
...bgStyle,
}}
>
@@ -88,35 +64,6 @@ export default function Portal() {
))}
</div>
</section>
<div className="shell" style={{ padding: '56px 0 12px' }}>
<nav className="grid-2" aria-label="Main destinations">
{DESTINATIONS.map((d) => (
<Link key={d.to} to={d.to} className="card" style={{ padding: 30 }}>
<span className="card-kicker" style={{ letterSpacing: '0.16em', marginBottom: 14 }}>
{d.kicker}
</span>
<strong
className="display"
style={{ fontSize: '1.7rem', color: 'var(--head)', marginBottom: 10, fontWeight: 600 }}
>
{d.title}
</strong>
<span className="muted">{d.body}</span>
</Link>
))}
</nav>
</div>
<div className="shell" style={{ padding: '24px 0 64px' }}>
<nav style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 10 }} aria-label="Quick links">
{QUICK.map((q) => (
<Link key={q.to} to={q.to} className="pill">
{q.label}
</Link>
))}
</nav>
</div>
</main>
</PublicLayout>
)

View File

@@ -613,6 +613,29 @@ button[disabled] {
color: var(--muted);
border: 1px solid var(--line);
}
.badge-moderator {
background: rgba(224, 176, 112, 0.12);
color: #e0b070;
border: 1px solid rgba(224, 176, 112, 0.4);
}
/* Action-type badges for the moderation dashboard. */
.badge-ban {
background: rgba(217, 139, 132, 0.16);
color: #d98b84;
border: 1px solid rgba(217, 139, 132, 0.4);
}
.badge-kick,
.badge-mute,
.badge-warn {
background: rgba(224, 176, 112, 0.12);
color: #e0b070;
border: 1px solid rgba(224, 176, 112, 0.4);
}
.badge-auto {
background: rgba(127, 153, 189, 0.14);
color: #9fb0c6;
border: 1px solid var(--line);
}
.link-accent {
color: var(--accent);
text-decoration: none;

View File

@@ -35,10 +35,46 @@ services:
- uploads:/app/uploads
# Bind-mount logs to the host so app.log is directly readable at ./logs/
- ./logs:/app/logs
# Only the PUBLIC API port (3000) is published. The internal server<->bot
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
# stays reachable only over the private compose network — Pangolin/the public
# reverse proxy can never forward to it. See issue #33.
# Binds 0.0.0.0 (no 127.0.0.1 prefix) so Pangolin can reach the container.
ports:
- "3000:3000"
bot:
build:
context: .
dockerfile: bot/Dockerfile
restart: unless-stopped
env_file: .env
environment:
DB_HOST: db
# Pin the bot's own listen port. Both services share env_file: .env, so
# without this the site's PORT=3000 leaks in and the bot binds 3000 instead
# of 4100 — then the server's BOT_INTERNAL_URL (http://bot:4100) can't reach
# it ("failed to fetch" in the admin panel). Must match that URL's port.
PORT: 4100
# Likewise override the log filename so the bot doesn't inherit the site's
# LOG_FILE and write into app.log — keep the bot's log distinct.
LOG_FILE: bot.log
# Internal config fetch goes to the app's UNPUBLISHED internal port (3001),
# not the public 3000. Keep the port in sync with the app's INTERNAL_PORT.
SITE_INTERNAL_URL: http://app:3001/internal/bot-config
SITE_PUBLIC_URL: http://app:3000/api/v1/public
LOG_DIR: /app/bot/logs
depends_on:
db:
condition: service_healthy
app:
condition: service_started
volumes:
- ./bot/logs:/app/bot/logs
# No published port — the bot's internal API (/internal/*) is reached only
# by `app` over the private compose network, and must NEVER be exposed
# through Pangolin/the public reverse proxy.
volumes:
dbdata:
uploads:

View File

@@ -6,9 +6,11 @@
"scripts": {
"install-server": "npm install --prefix server",
"install-client": "npm install --prefix client",
"install-all": "npm run install-server && npm run install-client",
"install-bot": "npm install --prefix bot",
"install-all": "npm run install-server && npm run install-client && npm run install-bot",
"server": "npm run dev --prefix server",
"client": "npm run dev --prefix client",
"bot": "npm run dev --prefix bot",
"seed": "npm run seed --prefix server",
"build": "npm run build --prefix client",
"start": "npm start --prefix server"

View File

@@ -4,6 +4,10 @@
NODE_ENV=development
PORT=3000
# Separate, unpublished port for server<->bot internal traffic (the decrypted
# bot-token route). Must match the port in bot/.env's SITE_INTERNAL_URL and must
# never be exposed through a public reverse proxy. See issue #33.
INTERNAL_PORT=3001
# Logging — written to BOTH the console and a log file (default <server>/logs/app.log).
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
FILE_LOG_LEVEL=debug # file verbosity
@@ -73,3 +77,15 @@ SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
CLIENT_ORIGIN=http://localhost:5173
# Discord bot — internal API (server <-> bot/). 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 blank, a documented
# placeholder, or shorter than 16 chars (a warning only in dev). The Discord bot
# TOKEN itself is not an env var — it's entered in the admin panel and stored
# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above).
BOT_INTERNAL_URL=http://localhost:4100
BOT_INTERNAL_KEY=dev-only-change-me-bot-key

View File

@@ -6,9 +6,12 @@ CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(32) NOT NULL UNIQUE,
password_hash VARCHAR(72) NOT NULL,
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin',
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
-- Any session token issued before this instant is rejected (see requireAuth).
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
tokens_valid_after DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -176,6 +179,272 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
INDEX idx_mrt_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
-- per session in createSession. A single logout adds this session's jti here;
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
-- mirrors the token's own exp, after which the JWT fails verification anyway, so
-- the row is dead weight and gets pruned. "Log out everywhere" / password change
-- do NOT use this table — they bump users.tokens_valid_after instead (one row vs.
-- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens.
CREATE TABLE IF NOT EXISTS revoked_sessions (
jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4)
user_id INT NULL,
expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after)
revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_revoked_sessions_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
-- client secrets are, and is only ever decrypted server-side to push to the
-- bot process over the internal API; it is never returned to the admin UI
-- and the bot process never reads this table directly. `status`/`status_detail`
-- /`last_connected_at` are last-known-state mirrors of what the bot reported,
-- shown in the admin panel between polls.
CREATE TABLE IF NOT EXISTS bot_config (
id INT PRIMARY KEY DEFAULT 1,
guild_id VARCHAR(32) NULL,
bot_token_enc TEXT NULL,
application_id VARCHAR(32) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL,
last_connected_at DATETIME NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_bot_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
-- writes them. They live in the same physical database as everything else
-- (per the spec's "shared instance, clearly prefixed where needed" option)
-- purely because there's no separate migration tooling to stand up a second
-- database for a single-guild v1 bot.
-- Per-guild key/value config the bot needs at runtime (currently just the
-- mod-log channel; filters/schedules/role-menu config lands here in later
-- phases). Set via the `/modlog set` slash command, not the admin panel —
-- unlike bot_config (identity/connection secrets), this is routine Discord
-- server administration staff already do inside Discord.
CREATE TABLE IF NOT EXISTS guild_config (
guild_id VARCHAR(32) NOT NULL,
`key` VARCHAR(64) NOT NULL,
value VARCHAR(500) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (guild_id, `key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Audit trail + mod-log source of truth for ban/kick/mute/warn actions.
-- duration_seconds is only set for timed mutes; NULL for permanent
-- ban/kick/warn actions.
CREATE TABLE IF NOT EXISTS mod_actions (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
action_type ENUM('ban','kick','mute','warn') NOT NULL,
target_user_id VARCHAR(32) NOT NULL,
target_tag VARCHAR(120) NULL,
staff_user_id VARCHAR(32) NOT NULL,
staff_tag VARCHAR(120) NULL,
reason VARCHAR(500) NULL,
duration_seconds INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Standing warnings, separate from mod_actions so /warnings can list active
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
-- yet — deferred, see mute/warn command comments) but the column is cheap to
-- add now rather than migrate in later.
CREATE TABLE IF NOT EXISTS warnings (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
target_user_id VARCHAR(32) NOT NULL,
target_tag VARCHAR(120) NULL,
staff_user_id VARCHAR(32) NOT NULL,
staff_tag VARCHAR(120) NULL,
reason VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NULL,
INDEX idx_warnings_target (guild_id, target_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Banned-word list (Phase 3). `word` is stored as the admin typed it; matching
-- normalizes both sides at runtime (case, leetspeak, repeated chars — see
-- bot/src/filter/normalize.js), so the stored value doesn't need every
-- obfuscated variant. severity drives the auto-action: delete-only, delete +
-- warn, or delete + mute (see messageFilter.js). The role/channel allowlist
-- that bypasses filtering entirely lives in guild_config (keys
-- filter_allow_roles / filter_allow_channels, CSV of snowflake ids) rather
-- than a separate table — it's a short, rarely-changed list.
CREATE TABLE IF NOT EXISTS filter_words (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
word VARCHAR(200) NOT NULL,
severity ENUM('delete','warn','mute') NOT NULL DEFAULT 'delete',
added_by VARCHAR(32) NULL,
added_by_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_filter_words_guild_word (guild_id, word)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Scheduled/recurring messages (Phase 4). A row is EITHER recurring
-- (cron_expression set, run_at NULL — reposts on the node-cron schedule
-- forever until disabled/removed) OR one-off (run_at set, cron_expression
-- NULL — posted once, then sent_at is stamped so the scheduler's due-message
-- sweep never reposts it). content is plain text for now — the original spec
-- allows richer embed JSON here, deferred since authoring embed JSON through a
-- single slash-command string option isn't practical without a modal/admin UI.
CREATE TABLE IF NOT EXISTS scheduled_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
channel_id VARCHAR(32) NOT NULL,
content VARCHAR(2000) NOT NULL,
cron_expression VARCHAR(100) NULL,
run_at DATETIME NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sent_at DATETIME NULL,
created_by VARCHAR(32) NULL,
created_by_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_schedule_kind CHECK (
(cron_expression IS NOT NULL AND run_at IS NULL) OR
(cron_expression IS NULL AND run_at IS NOT NULL)
),
INDEX idx_scheduled_due (run_at, sent_at, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Self-assignable role menus (Phase 5). Button-based, not reaction-based —
-- avoids needing the messageReactionAdd/Remove events and their own intent.
-- `mapping` is a JSON array of {roleId, label}, validated against at click
-- time (see bot/src/discord/roleMenuHandler.js) so a stale/foreign button
-- customId can't toggle an untracked role. Auto-role-on-join is simpler and
-- reuses guild_config (key auto_role_id) rather than a table of its own.
CREATE TABLE IF NOT EXISTS role_menus (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
channel_id VARCHAR(32) NOT NULL,
message_id VARCHAR(32) NOT NULL,
mapping TEXT NOT NULL,
created_by VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_role_menus_message (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Timed role assignments (temp-mute-equivalent roles, timed event roles).
-- Swept once a minute (bot/src/roles/tempRoleSweeper.js) — expired rows have
-- their Discord role removed and the row deleted. UNIQUE(guild,user,role) so
-- re-granting the same temp role just refreshes its expiry via ON DUPLICATE
-- KEY UPDATE rather than stacking duplicate rows.
CREATE TABLE IF NOT EXISTS temp_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
role_id VARCHAR(32) NOT NULL,
expires_at DATETIME NOT NULL,
created_by VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_temp_roles_user_role (guild_id, user_id, role_id),
INDEX idx_temp_roles_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Audit trail for the auto-rotating primary invite (Phase 6). triggered_by
-- NULL means the weekly scheduled rotation did it, not a staff member — see
-- bot/src/invites/inviteRotator.js, shared by both /invite rotate and the
-- cron job so both paths log identically. The channel invites are created in
-- is configured separately in guild_config (key invite_channel_id).
CREATE TABLE IF NOT EXISTS invite_log (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
channel_id VARCHAR(32) NOT NULL,
invite_code VARCHAR(20) NOT NULL,
triggered_by VARCHAR(32) NULL,
triggered_by_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME NULL,
INDEX idx_invite_log_guild (guild_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves
-- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/
-- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_*
-- record which invite was used when the bot could attribute it (best-effort, see
-- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves.
-- These are member lifecycle events, not moderation actions, hence separate from
-- mod_actions.
CREATE TABLE IF NOT EXISTS member_events (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
event_type ENUM('join','leave') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
invite_code VARCHAR(20) NULL,
inviter_id VARCHAR(32) NULL,
inviter_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_member_events_guild (guild_id, created_at),
INDEX idx_member_events_user (guild_id, discord_user_id, created_at),
INDEX idx_member_events_invite (guild_id, invite_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated content-filter hits (Phase 6b): one row per message the word filter
-- or the foreign-invite filter deleted. Separate from mod_actions (which still
-- records the resulting warn/mute) so the dashboard can show filter volume in
-- its own right. `matched` holds the offending word (word hits) or the blocked
-- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned
-- (bot/src/discord/messageFilter.js).
CREATE TABLE IF NOT EXISTS filter_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
hit_type ENUM('word','invite') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
matched VARCHAR(200) NULL,
action_taken ENUM('delete','warn','mute') NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filter_hits_guild (guild_id, created_at),
INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention /
-- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting
-- warn; this records the detection itself for the dashboard's spam feed.
-- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js).
CREATE TABLE IF NOT EXISTS spam_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_spam_hits_guild (guild_id, created_at),
INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff notes on a Discord user, surfaced in the admin moderation dashboard
-- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written
-- and read only by the main site (moderation.controller), never by the bot.
-- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so
-- notes attach to a Discord identity even when it has no linked site account.
-- Notes are never user-visible; admin_only notes are further restricted to the
-- admin role (moderators see staff_only only) — enforced in the query layer.
CREATE TABLE IF NOT EXISTS mod_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
discord_user_id VARCHAR(32) NOT NULL,
author_user_id INT NULL,
author_tag VARCHAR(120) NULL,
body TEXT NOT NULL,
visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_mod_notes_user (discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
@@ -184,6 +453,12 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
-- Opt-in TOTP two-factor columns for databases created before login hardening.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
-- Session-revocation cutoff for databases created before token revocation landed.
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
-- an idempotent no-op, so it is safe to run on every boot.
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin';
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;

182
server/package-lock.json generated
View File

@@ -25,12 +25,21 @@
"nodemailer": "^9.0.1",
"qrcode": "^1.5.4",
"sanitize-html": "^2.17.5",
"speakeasy": "^2.0.0"
"speakeasy": "^2.0.0",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"nodemon": "^3.1.4"
"nodemon": "^3.1.4",
"swagger-autogen": "^2.23.7"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -59,6 +68,19 @@
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -336,6 +358,13 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -845,6 +874,13 @@
"node": ">= 0.6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -915,6 +951,28 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -928,6 +986,37 @@
"node": ">= 6"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1041,6 +1130,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1129,6 +1230,19 @@
"node": ">=0.10.0"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1560,6 +1674,16 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -1620,6 +1744,16 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -2082,6 +2216,43 @@
"node": ">=4"
}
},
"node_modules/swagger-autogen": {
"version": "2.23.7",
"resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz",
"integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^7.4.1",
"deepmerge": "^4.2.2",
"glob": "^7.1.7",
"json5": "^2.2.3"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.8",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2208,6 +2379,13 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View File

@@ -7,6 +7,7 @@
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"seed": "node db/seed.js",
"swagger": "node swagger/swagger.js",
"test": "node --test"
},
"keywords": [
@@ -34,9 +35,11 @@
"nodemailer": "^9.0.1",
"qrcode": "^1.5.4",
"sanitize-html": "^2.17.5",
"speakeasy": "^2.0.0"
"speakeasy": "^2.0.0",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"nodemon": "^3.1.4"
"nodemon": "^3.1.4",
"swagger-autogen": "^2.23.7"
}
}

View File

@@ -7,6 +7,8 @@ const morgan = require('morgan')
const cookieParser = require('cookie-parser')
require('dotenv').config()
const swaggerUi = require('swagger-ui-express')
const apiRouter = require('./router/api.router')
const createLogger = require('./utils/logger')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
@@ -75,8 +77,35 @@ app.use(
}),
)
// ── API docs (Swagger UI) ─────────────────────────────────────────────
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
// is generated from route annotations by `npm run swagger` (server/swagger/).
// Loaded lazily and guarded so a missing spec never crashes the server.
try {
// eslint-disable-next-line global-require
const swaggerSpec = require('../swagger/swagger-output.json')
app.get('/api/docs.json', (req, res) => {
// #swagger.ignore = true
res.json(swaggerSpec)
})
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customSiteTitle: 'UOMysticmoon API docs',
swaggerOptions: { persistAuthorization: true },
}))
} catch (err) {
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
message: err.message,
})
}
// ── API ───────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => res.json({ status: 'ok' }))
app.get(
'/api/health',
// #swagger.tags = ['Health']
// #swagger.summary = 'Liveness probe'
/* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */
(req, res) => res.json({ status: 'ok' }),
)
app.use('/api', apiRouter)
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))

View File

@@ -15,6 +15,18 @@ const sessionService = require('./session.service')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
// True if this session was issued at or before the user's tokens_valid_after
// cutoff (i.e. revoked by a password change / log-out-everywhere). Both the JWT
// iat and the cutoff are second-granular, so the comparison is inclusive: a token
// minted in the same second as the bump must still be revoked (otherwise it would
// survive its full lifetime through that 1s alignment). The only cost is that a
// re-login within the same second as the change is rejected until the next second
// — a self-healing blip, and far preferable to leaving a stale token valid.
function isBeforeCutoff(session, tokensValidAfter) {
if (!tokensValidAfter || session.createdAt == null) return false
return session.createdAt <= new Date(tokensValidAfter).getTime()
}
// Best-effort: if the request carries a valid session token, attach the decoded
// session (no DB hit), its auth method, and request metadata. Never rejects —
// anonymous requests simply pass through with req.session undefined.
@@ -38,6 +50,18 @@ async function requireAuth(req, res, next) {
try {
const user = await users.getById(session.userId)
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
// Revocation, enforced here (not in stateless token verification):
// 1. per-user cutoff — password change / "log out everywhere" bumps
// tokens_valid_after; any token issued before it is dead.
// 2. per-session denylist — a single logout adds this jti to revoked_sessions.
if (isBeforeCutoff(session, user.tokens_valid_after)) {
return res.status(401).json({ message: 'Unauthorized' })
}
if (await sessionService.isSessionRevoked(session.sessionId)) {
return res.status(401).json({ message: 'Unauthorized' })
}
req.user = user
req.session = session
req.authMethod = session.authMethod

View File

@@ -14,16 +14,20 @@
// role,
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
// createdAt, // ms epoch the token was issued (JWT iat)
// expiresAt, // ms epoch the token expires (JWT exp), or null
// lastSeenAt, // ms epoch this session was last validated
// }
//
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
// server-side session store yet — these are documented hook points for a future
// store (e.g. a denylist of jti, or mobile refresh-token records).
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
// denylist (revoked_sessions — single logout) and a per-user cutoff
// (users.tokens_valid_after — password change / log out everywhere). requireAuth
// consults both. The functions here are the seam the controllers call.
const crypto = require('crypto')
const token = require('./token')
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
@@ -32,10 +36,24 @@ const log = require('../utils/logger')('session')
// authenticated without changing this module per provider.
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
// The claim that positively marks a token as a real, full session. Every JWT in
// the app is signed with the same secret and is distinguished only by claims, so
// a session must be identified by what it *is* (typ === 'session'), never by the
// mere absence of some other marker. Only the session-minting paths below stamp
// it; flow/challenge tokens (the TOTP challenge, the SSO transaction cookie) do
// not, so — even though they verify against the same secret — they can never be
// mistaken for a session. See issue #32 (sso_tx token-type confusion).
const SESSION_TYP = 'session'
// Build a Session object from a decoded JWT payload. Returns null for anything
// that is not a full session (e.g. a stage-tagged TOTP challenge token).
// that is not a full session. Validation is positively typed: a token qualifies
// only if it was explicitly minted as a session. As belt-and-suspenders we also
// reject any token carrying a non-session marker (stage = TOTP challenge, kind =
// SSO transaction), so a future minting path that forgets to omit those still
// can't produce an accepted session.
function sessionFromDecoded(decoded, now = Date.now()) {
if (!decoded || decoded.stage) return null
if (!decoded || decoded.typ !== SESSION_TYP) return null
if (decoded.stage || decoded.kind) return null
return {
sessionId: decoded.jti || null,
userId: decoded.id,
@@ -43,6 +61,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
role: decoded.role,
authMethod: decoded.authMethod || 'local',
createdAt: decoded.iat ? decoded.iat * 1000 : null,
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
lastSeenAt: now,
}
}
@@ -56,7 +75,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
function createSession(user, authMethod = 'local') {
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
const sessionId = crypto.randomUUID()
const raw = token.signToken(user, { authMethod: method, jti: sessionId })
const raw = token.signToken(user, { authMethod: method, jti: sessionId, typ: SESSION_TYP })
const session = sessionFromDecoded(token.verifyToken(raw))
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
return { token: raw, session }
@@ -124,7 +143,7 @@ function mintMobileTokens(user, meta = {}, now = Date.now()) {
const sessionId = crypto.randomUUID()
const accessToken = token.signToken(
user,
{ authMethod: 'mobile', jti: sessionId },
{ authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
{ expiresIn: MOBILE_ACCESS_TTL },
)
// 256 bits of entropy, url-safe. Opaque — carries no claims.
@@ -179,23 +198,52 @@ function sessionMeta(req) {
return { ip, userAgent, deviceHash }
}
// ── Revocation / invalidation (stubs) ──────────────────────────────────────
// JWTs are stateless: there is no store to revoke against yet. These are the
// hook points a future session store (jti denylist, mobile refresh records)
// will implement. They log and report success so callers can wire them in now.
// ── Revocation / invalidation ──────────────────────────────────────────────
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
// two server-side stores these functions write:
// • revoked_sessions — a per-session jti denylist (single logout)
// • users.tokens_valid_after — a per-user cutoff (log out everywhere)
// A jti + its expiry (from the decoded token) are needed to denylist one session;
// invalidating all of a user's sessions only needs their id.
function revokeSession(sessionId) {
log.info('revokeSession (stub — no session store yet)', { sessionId })
// Revoke a single session by its jti. Needs the token's expiry so the denylist
// row can self-prune once the JWT would fail verification anyway. Idempotent.
async function revokeSession(sessionId, { userId = null, expiresAt } = {}) {
if (!sessionId) {
log.warn('revokeSession called without a sessionId (jti) — nothing to revoke')
return false
}
// Fall back to the max JWT lifetime if the caller didn't pass the token's exp,
// so the denylist row still outlives any token carrying this jti.
const exp = expiresAt || Date.now() + token.cookieMaxAge()
await revokedSessions.revoke({ jti: sessionId, userId, expiresAt: exp })
log.info('session revoked', { sessionId, userId })
return true
}
function invalidateSession(sessionId) {
log.info('invalidateSession (stub — no session store yet)', { sessionId })
return true
// Alias kept for callers that speak of "invalidating" one session.
async function invalidateSession(sessionId, opts) {
return revokeSession(sessionId, opts)
}
function invalidateAllUserSessions(userId) {
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
// Has this session (jti) been individually revoked? Used by requireAuth on every
// authenticated request. Broad "valid after" cutoffs are checked separately by
// the middleware against the fresh user row it already loads.
async function isSessionRevoked(sessionId) {
if (!sessionId) return false
return revokedSessions.isRevoked(sessionId)
}
// Invalidate every session a user holds (password change / log out everywhere)
// by advancing their tokens_valid_after cutoff. Covers cookie sessions issued
// before now regardless of jti.
async function invalidateAllUserSessions(userId) {
if (!userId) {
log.warn('invalidateAllUserSessions called without a userId')
return false
}
await users.invalidateSessions(userId)
log.info('all user sessions invalidated', { userId })
return true
}
@@ -209,6 +257,7 @@ module.exports = {
sessionMeta,
revokeSession,
invalidateSession,
isSessionRevoked,
invalidateAllUserSessions,
// Mobile bearer sessions.
createMobileSession,

View File

@@ -19,6 +19,14 @@ const token = require('./token')
const TX_COOKIE = 'sso_tx'
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
// Second leg of an SSO login for an account that has TOTP enabled. The callback
// authenticated the user with the IdP but must NOT bypass their second factor
// (see issue #31), so instead of minting a session it stages this signed,
// httpOnly cookie and routes the browser through the TOTP form — mirroring the
// local password→TOTP gate. TTL matches the local challenge window.
const TOTP_COOKIE = 'sso_totp'
const TOTP_TTL = '5m'
// base64url of random bytes — used for the nonce and the PKCE verifier.
function randomUrlSafe(bytes = 32) {
return crypto.randomBytes(bytes).toString('base64url')
@@ -56,4 +64,38 @@ function verifyTx(txToken, stateNonce) {
return decoded
}
module.exports = { TX_COOKIE, TX_TTL, createTx, verifyTx, codeChallengeFor, randomUrlSafe }
// Stage the pending second factor for an SSO login. Carries the context the
// callback already resolved (userId, provider, authMethod, returnTo) so that
// presenting a valid code alone finishes the login. It is deliberately NOT a
// session: `stage: 'totp'` makes session validation reject it (same marker the
// local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and
// scopes it to the SSO completion endpoint.
function createTotpPending({ userId, provider, authMethod, returnTo }) {
return token.signToken(
{ id: userId }, // subject only; identity is re-loaded fresh when the code is verified
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo },
{ expiresIn: TOTP_TTL },
)
}
// Verify a pending-TOTP cookie. Returns the payload
// ({ id, provider, authMethod, returnTo, ... }) or null if missing/expired/wrong-kind.
function verifyTotpPending(pendingToken) {
if (!pendingToken) return null
const decoded = token.verifyToken(pendingToken)
if (!decoded || decoded.stage !== 'totp' || decoded.kind !== 'sso_totp') return null
return decoded
}
module.exports = {
TX_COOKIE,
TX_TTL,
TOTP_COOKIE,
TOTP_TTL,
createTx,
verifyTx,
createTotpPending,
verifyTotpPending,
codeChallengeFor,
randomUrlSafe,
}

26
server/src/internalApp.js Normal file
View File

@@ -0,0 +1,26 @@
// Standalone Express app for server<->bot internal traffic. It is mounted on its
// OWN http listener (INTERNAL_PORT, default 3001) in server.js — an unpublished,
// compose-network-only port, mirroring how the bot exposes its internal API on
// 4100. Crucially it is NOT part of the public API app (app.js), so /internal/*
// (which returns the DECRYPTED Discord bot token) can never ride the same
// listener Pangolin proxies to the world. Shared-secret gated by
// requireInternalKey inside internal.routes. See issue #33.
const express = require('express')
const internalRouter = require('./router/v1/internal/internal.routes')
const internalApp = express()
internalApp.use(express.json())
// Liveness probe for this listener (no secret required); mirrors the bot's
// /health on 4100. Useful for compose healthchecks without exposing anything.
internalApp.get('/health', (req, res) => res.json({ status: 'ok' }))
// requireInternalKey is applied inside internal.routes.
internalApp.use('/internal', internalRouter)
// Anything else on this listener is not a real internal route.
internalApp.use((req, res) => res.status(404).json({ message: 'Not found' }))
module.exports = internalApp

View File

@@ -0,0 +1,20 @@
// Gate for server-side /internal/* routes. The only caller is the bot process,
// on its own boot, 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. Same pattern as
// bot/src/internal/requireInternalKey.js on the other side of this call.
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

View File

@@ -0,0 +1,28 @@
const { query } = require('../../utils/db')
const COLS =
'id, guild_id, bot_token_enc, application_id, enabled, status, status_detail, last_connected_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM bot_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO bot_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }

View File

@@ -0,0 +1,73 @@
// Discord bot config store (Phase 1). Mirrors the authProviders model split:
// the DB layer only ever sees ciphertext, and only getWithToken() (used
// internally to push config to the bot process / to the bot-config internal
// endpoint) decrypts it. The admin-facing getSafe() never includes the token.
const db = require('./botConfig.db')
const secretBox = require('../../utils/secretBox')
function toSafe(row) {
if (!row) {
return {
guildId: null,
applicationId: null,
enabled: false,
hasToken: false,
status: 'disconnected',
statusDetail: null,
lastConnectedAt: null,
}
}
return {
guildId: row.guild_id || null,
applicationId: row.application_id || null,
enabled: Boolean(row.enabled),
hasToken: Boolean(row.bot_token_enc),
status: row.status || 'disconnected',
statusDetail: row.status_detail || null,
lastConnectedAt: row.last_connected_at || null,
}
}
async function getSafe() {
return toSafe(await db.get())
}
// Decrypted token included — server-side only (pushing config to the bot, or
// serving the shared-secret-gated /internal/bot-config route).
async function getWithToken() {
const row = await db.get()
if (!row) return null
return { ...toSafe(row), token: row.bot_token_enc ? secretBox.decrypt(row.bot_token_enc) : null }
}
// Save admin-supplied config. `token` undefined or '' means "leave the
// existing token unchanged" (same convention as authProviders.save).
async function save({ guildId, applicationId, token, enabled, updatedBy }) {
const fields = {}
if (guildId !== undefined) fields.guild_id = guildId
if (applicationId !== undefined) fields.application_id = applicationId
if (token) fields.bot_token_enc = secretBox.encrypt(token)
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (updatedBy !== undefined) fields.updated_by = updatedBy
const row = await db.upsert(fields)
return toSafe(row)
}
// Mirror the bot's last-reported status into the DB so the admin panel has
// something to show even if the bot is briefly unreachable.
async function recordStatus({ status, statusDetail, lastConnectedAt }) {
const fields = {}
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail
// lastConnectedAt arrives over HTTP as a JSON-serialized ISO string (e.g.
// "2026-07-04T18:49:51.429Z") — MariaDB's DATETIME parser rejects the "T"/
// "Z"/milliseconds in that format. Convert to a real Date so the mariadb
// driver formats it correctly on the wire.
if (lastConnectedAt !== undefined) fields.last_connected_at = lastConnectedAt ? new Date(lastConnectedAt) : null
if (Object.keys(fields).length === 0) return getSafe()
const row = await db.upsert(fields)
return toSafe(row)
}
module.exports = { getSafe, getWithToken, save, recordStatus }

View File

@@ -0,0 +1,51 @@
// Staff notes on a Discord user (server-owned, see db/schema.sql mod_notes).
// Notes are never user-visible; admin_only notes are filtered out for non-admin
// callers at this layer via includeAdminOnly.
const { query } = require('../../utils/db')
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
const visClause = includeAdminOnly ? '' : "AND n.visibility = 'staff_only'"
return query(
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
n.body, n.visibility, n.created_at,
u.username AS author_username
FROM mod_notes n
LEFT JOIN users u ON u.id = n.author_user_id
WHERE n.discord_user_id = ? ${visClause}
ORDER BY n.id DESC`,
[discordId],
)
}
async function insert({ discordUserId, authorUserId = null, authorTag = null, body, visibility = 'staff_only' }) {
const res = await query(
`INSERT INTO mod_notes (discord_user_id, author_user_id, author_tag, body, visibility)
VALUES (?, ?, ?, ?, ?)`,
[discordUserId, authorUserId, authorTag, body, visibility],
)
return res.insertId
}
async function getById(id) {
const rows = await query(
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
n.body, n.visibility, n.created_at,
u.username AS author_username
FROM mod_notes n
LEFT JOIN users u ON u.id = n.author_user_id
WHERE n.id = ? LIMIT 1`,
[id],
)
return rows[0] || null
}
async function countForUser(discordId, { includeAdminOnly = false } = {}) {
const visClause = includeAdminOnly ? '' : "AND visibility = 'staff_only'"
const rows = await query(
`SELECT COUNT(*) AS c FROM mod_notes WHERE discord_user_id = ? ${visClause}`,
[discordId],
)
return Number(rows[0].c)
}
module.exports = { listForUser, insert, getById, countForUser }

View File

@@ -0,0 +1,18 @@
const modNotesDb = require('./modNotes.db')
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
return modNotesDb.listForUser(discordId, { includeAdminOnly })
}
async function add({ discordUserId, author, body, visibility = 'staff_only' }) {
const id = await modNotesDb.insert({
discordUserId,
authorUserId: author ? author.id : null,
authorTag: author ? author.username : null,
body,
visibility,
})
return modNotesDb.getById(id)
}
module.exports = { listForUser, add }

View File

@@ -0,0 +1,188 @@
// Read-only access to the bot-owned moderation tables (mod_actions) for the
// admin moderation dashboard (Phase 6). These tables are normally owned by the
// bot process (bot/src/db.js) — see the comment in db/schema.sql — but they live
// in the same physical database, so the site reads them directly through the
// shared pool rather than round-tripping the bot over the internal API. This
// module NEVER writes them; all writes still belong to the bot.
//
// mod_actions is the single source of truth for ban/kick/mute/warn (every warn
// command also mirrors into `warnings`, so counting mod_actions avoids double
// counting). Accounts are correlated to Discord ids via user_identities
// (provider='discord', subject=<snowflake>), the same link the SSO flow writes.
const { query } = require('../../utils/db')
const TYPES = ['ban', 'kick', 'mute', 'warn']
// Per-type counts across three nested windows in a single scan. Boolean
// comparisons yield 1/0 in MariaDB, so SUM(created_at >= cutoff) counts the
// rows inside each window. Returns raw rows: [{ action_type, d1, d7, d30 }].
async function countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
return query(
`SELECT action_type,
SUM(created_at >= ?) AS d1,
SUM(created_at >= ?) AS d7,
SUM(created_at >= ?) AS d30
FROM mod_actions
WHERE created_at >= ?
GROUP BY action_type`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
}
const ACTION_SELECT = `
SELECT ma.id, ma.guild_id, ma.action_type,
ma.target_user_id, ma.target_tag,
ma.staff_user_id, ma.staff_tag,
ma.reason, ma.duration_seconds, ma.created_at,
ui.user_id AS target_site_user_id,
u.username AS target_site_username
FROM mod_actions ma
LEFT JOIN user_identities ui
ON ui.provider = 'discord' AND ui.subject = ma.target_user_id
LEFT JOIN users u ON u.id = ui.user_id`
// Most-recent-first action feed, optionally filtered by type. limit/offset
// pagination matching the activity-log convention.
async function recentActions({ type = null, limit = 50, offset = 0 } = {}) {
const where = type ? 'WHERE ma.action_type = ?' : ''
const params = type ? [type, limit, offset] : [limit, offset]
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
}
// Full action history for one Discord user, optionally filtered by type.
async function userActions(discordId, { type = null, limit = 50, offset = 0 } = {}) {
const where = type
? 'WHERE ma.target_user_id = ? AND ma.action_type = ?'
: 'WHERE ma.target_user_id = ?'
const params = type ? [discordId, type, limit, offset] : [discordId, limit, offset]
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
}
// All-time per-type counts for one user.
async function userCounts(discordId) {
return query(
`SELECT action_type, COUNT(*) AS c FROM mod_actions
WHERE target_user_id = ? GROUP BY action_type`,
[discordId],
)
}
// Latest username snapshot the bot recorded for this Discord id (usernames drift).
async function latestTag(discordId) {
const rows = await query(
'SELECT target_tag FROM mod_actions WHERE target_user_id = ? ORDER BY id DESC LIMIT 1',
[discordId],
)
return rows[0] ? rows[0].target_tag : null
}
// Linked site account for a Discord id, if any (via user_identities).
async function linkedAccount(discordId) {
const rows = await query(
`SELECT u.id, u.username, u.role
FROM user_identities ui
JOIN users u ON u.id = ui.user_id
WHERE ui.provider = 'discord' AND ui.subject = ?
LIMIT 1`,
[discordId],
)
return rows[0] || null
}
// ── Phase 6b: member events + filter/spam hits (bot-owned, read-only) ──
// Join/leave counts per window (grouped by event_type).
async function memberCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
return query(
`SELECT event_type,
SUM(created_at >= ?) AS d1,
SUM(created_at >= ?) AS d7,
SUM(created_at >= ?) AS d30
FROM member_events
WHERE created_at >= ?
GROUP BY event_type`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
}
// Attributed-invite join counts per window (joins whose invite we identified).
async function inviteJoinCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM member_events
WHERE event_type = 'join' AND invite_code IS NOT NULL AND created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
// Row-count per window for a simple event table. `table` is an internal literal
// ('filter_hits' | 'spam_hits'), never user input — see the caller allowlist.
async function tableCountsByWindow(table, { cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM ${table} WHERE created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
async function recentMemberEvents({ type = null, limit = 50, offset = 0 } = {}) {
const where = type ? 'WHERE event_type = ?' : ''
const params = type ? [type, limit, offset] : [limit, offset]
return query(
`SELECT id, guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag, created_at
FROM member_events ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
params,
)
}
async function recentFilterHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken, created_at
FROM filter_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
async function recentSpamHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, spam_type, discord_user_id, username, channel_id, created_at
FROM spam_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
// User-lookup: match a Discord id exactly, or a username snapshot (target_tag)
// by prefix, returning the most recently seen distinct targets. Powers the
// dashboard search box (usernames drift, so we search historical snapshots too).
async function searchTargets(term, { limit = 20 } = {}) {
return query(
`SELECT ma.target_user_id, MAX(ma.target_tag) AS target_tag,
COUNT(*) AS action_count, MAX(ma.created_at) AS last_seen
FROM mod_actions ma
WHERE ma.target_user_id = ? OR ma.target_tag LIKE ?
GROUP BY ma.target_user_id
ORDER BY last_seen DESC
LIMIT ?`,
[term, `${term}%`, limit],
)
}
module.exports = {
TYPES,
countsByWindow,
recentActions,
userActions,
userCounts,
latestTag,
linkedAccount,
searchTargets,
// Phase 6b
memberCountsByWindow,
inviteJoinCountsByWindow,
tableCountsByWindow,
recentMemberEvents,
recentFilterHits,
recentSpamHits,
}

View File

@@ -0,0 +1,105 @@
// Business logic for the moderation dashboard: reshapes the raw mod_actions
// reads into the shapes the admin UI consumes, and annotates each action with
// whether it was an automated (bot) action. For a Discord bot the application_id
// IS the bot's user id, and the filter/spam pipeline records automated actions
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
// staff_user_id === bot_config.application_id reliably flags automated actions
// without needing new columns on mod_actions.
const moderationDb = require('./moderation.db')
const botConfigDb = require('../botConfig/botConfig.db')
const { zeroCounts, annotate, reshapeWindows, windowValue } = require('./moderation.pure')
const DAY_MS = 24 * 60 * 60 * 1000
const WINDOW_KEYS = ['24h', '7d', '30d']
async function botApplicationId() {
try {
const cfg = await botConfigDb.get()
return cfg ? cfg.application_id : null
} catch {
return null
}
}
// Counts by type across 24h / 7d / 30d windows for the overview tiles. Covers
// moderation actions (mod_actions) plus the Phase 6b event streams: member
// joins/leaves, attributed invite joins, and filter/spam hits.
async function summary() {
const now = Date.now()
const cutoffs = {
cutoff24h: new Date(now - DAY_MS),
cutoff7d: new Date(now - 7 * DAY_MS),
cutoff30d: new Date(now - 30 * DAY_MS),
}
const [modRows, memberRows, inviteRow, filterRow, spamRow] = await Promise.all([
moderationDb.countsByWindow(cutoffs),
moderationDb.memberCountsByWindow(cutoffs),
moderationDb.inviteJoinCountsByWindow(cutoffs),
moderationDb.tableCountsByWindow('filter_hits', cutoffs),
moderationDb.tableCountsByWindow('spam_hits', cutoffs),
])
const windows = reshapeWindows(modRows).windows
const joinRow = memberRows.find((r) => r.event_type === 'join')
const leaveRow = memberRows.find((r) => r.event_type === 'leave')
for (const w of WINDOW_KEYS) {
windows[w].joins = windowValue(joinRow, w)
windows[w].leaves = windowValue(leaveRow, w)
windows[w].invite_joins = windowValue(inviteRow, w)
windows[w].filter_hits = windowValue(filterRow, w)
windows[w].spam_hits = windowValue(spamRow, w)
}
return { windows }
}
// Recent event feeds for the overview's secondary panel (Phase 6b).
async function members(opts) {
return moderationDb.recentMemberEvents(opts)
}
async function filterHits(opts) {
return moderationDb.recentFilterHits(opts)
}
async function spamHits(opts) {
return moderationDb.recentSpamHits(opts)
}
async function recent(opts) {
const appId = await botApplicationId()
return annotate(await moderationDb.recentActions(opts), appId)
}
async function userActions(discordId, opts) {
const appId = await botApplicationId()
return annotate(await moderationDb.userActions(discordId, opts), appId)
}
// Header data for the per-user history page: latest known tag, linked site
// account (if any), and all-time counts per action type.
async function userSummary(discordId) {
const [countRows, tag, linked] = await Promise.all([
moderationDb.userCounts(discordId),
moderationDb.latestTag(discordId),
moderationDb.linkedAccount(discordId),
])
const counts = zeroCounts()
let total = 0
for (const row of countRows) {
const c = Number(row.c) || 0
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
total += c
}
return {
discord_user_id: discordId,
tag,
linked_account: linked,
counts,
total_actions: total,
}
}
async function search(term, opts) {
return moderationDb.searchTargets(term, opts)
}
module.exports = { summary, recent, userActions, userSummary, search, members, filterHits, spamHits }

View File

@@ -0,0 +1,47 @@
// Pure reshaping/annotation helpers for the moderation dashboard, deliberately
// free of any DB (or other side-effecting) imports so they can be unit-tested
// without opening a database pool. moderation.model re-exports these.
function zeroCounts() {
return { ban: 0, kick: 0, mute: 0, warn: 0 }
}
// Tag each action as automated (staff is the bot) and fold the joined
// user_identities columns into a linked_account object. The string coercion
// matters — snowflakes can arrive as number or string from different columns.
function annotate(rows, appId) {
return rows.map((r) => {
const isAutomated = appId != null && String(r.staff_user_id) === String(appId)
return {
...r,
is_automated: isAutomated,
linked_account: r.target_site_user_id
? { id: r.target_site_user_id, username: r.target_site_username }
: null,
}
})
}
// Fold the per-type window rows into the { windows: { '24h', '7d', '30d' } }
// shape the dashboard tiles consume, zero-filling any type with no rows.
function reshapeWindows(rows) {
const windows = { '24h': zeroCounts(), '7d': zeroCounts(), '30d': zeroCounts() }
for (const row of rows) {
const t = row.action_type
if (windows['24h'][t] === undefined) continue
windows['24h'][t] = Number(row.d1) || 0
windows['7d'][t] = Number(row.d7) || 0
windows['30d'][t] = Number(row.d30) || 0
}
return { windows }
}
// Pull the count for one window key ('24h'|'7d'|'30d') out of a
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
function windowValue(row, key) {
if (!row) return 0
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
return Number(col) || 0
}
module.exports = { zeroCounts, annotate, reshapeWindows, windowValue }

View File

@@ -0,0 +1,40 @@
const { query } = require('../../utils/db')
// SQL for the revoked_sessions denylist. Rows are keyed on a session's JWT `jti`
// and carry the token's own expiry so they can be pruned once the underlying JWT
// would fail verification anyway. This is the web/cookie analogue of
// mobile_refresh_tokens (opaque, DB-stored, revocable).
// Add a jti to the denylist. INSERT IGNORE makes a repeat logout of the same
// session a harmless no-op (the PK already exists). Returns rows changed.
async function add({ jti, userId = null, expiresAt }) {
const res = await query(
`INSERT IGNORE INTO revoked_sessions (jti, user_id, expires_at)
VALUES (?, ?, ?)`,
[jti, userId, new Date(expiresAt)],
)
return Number(res.affectedRows || 0)
}
// True if this jti is on the denylist and not yet past its stored expiry. Past
// expiry the token itself is already invalid, so a lingering row need not match.
async function isRevoked(jti) {
if (!jti) return false
const rows = await query(
'SELECT 1 FROM revoked_sessions WHERE jti = ? AND expires_at > NOW() LIMIT 1',
[jti],
)
return rows.length > 0
}
// Housekeeping: drop rows whose token has already expired. Returns rows removed.
async function pruneExpired() {
const res = await query('DELETE FROM revoked_sessions WHERE expires_at < NOW()')
return Number(res.affectedRows || 0)
}
module.exports = {
add,
isRevoked,
pruneExpired,
}

View File

@@ -0,0 +1,29 @@
// Web/cookie session denylist. Thin logic layer over revokedSessions.db — mirrors
// the users/mobileSessions split (.db = SQL, .model = the API the rest of the app
// calls). A "revoked session" is a single JWT jti added on logout; requireAuth
// checks isRevoked on every authenticated request. Broad invalidation
// ("everywhere" / password change) does NOT live here — it bumps
// users.tokens_valid_after instead.
const db = require('./revokedSessions.db')
// Add a session's jti to the denylist (single-session logout). Idempotent.
async function revoke({ jti, userId, expiresAt }) {
return db.add({ jti, userId, expiresAt })
}
// True if the given jti has been revoked (and its token hasn't expired yet).
async function isRevoked(jti) {
return db.isRevoked(jti)
}
// Drop denylist rows whose token has already expired.
async function pruneExpired() {
return db.pruneExpired()
}
module.exports = {
revoke,
isRevoked,
pruneExpired,
}

Some files were not shown because too many files have changed in this diff Show More