Compare commits
39 Commits
ad9c556c9a
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4c4c5235 | |||
| 3027bb0400 | |||
| b0c0d1fe9b | |||
| f2691959ff | |||
| 60d2121b83 | |||
| 20d3fbf594 | |||
| f8db61025b | |||
| 2067028070 | |||
| 03e62b56ad | |||
| 15cf8ea286 | |||
| 5f62eccdd8 | |||
| e8a54d9ff7 | |||
| 933206a1b8 | |||
| 1cfb79f5ae | |||
| 3ef84b41ef | |||
| 5df943095d | |||
| bb5cc68c54 | |||
| 17c1eb07e8 | |||
| 7a21cc636c | |||
| ad7aebb3ba | |||
| 0318d6fe9f | |||
| 433e02d3ef | |||
| a1f0675577 | |||
| d7fb274bad | |||
| 6af85c30b6 | |||
| 86e44a94a2 | |||
| 31b31c3a17 | |||
| 8fa34ca68e | |||
| 870971fc12 | |||
| 58852a5078 | |||
| cd678e75ce | |||
| a82f839c61 | |||
| 05933f8d94 | |||
| 073c010d72 | |||
| f305019c54 | |||
| 7e8ffeee6f | |||
| 6ab3e47d38 | |||
| ea46b5d346 | |||
| d38c98ad9e |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
38
.env.example
38
.env.example
@@ -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
|
||||
@@ -28,6 +33,21 @@ JWT_EXPIRES_IN=1d
|
||||
COOKIE_SECURE=auto
|
||||
COOKIE_NAME=uomm_token
|
||||
|
||||
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
||||
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
||||
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
|
||||
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
|
||||
# Integer hop count or "false" also accepted; a blanket "true" is rejected
|
||||
# (coerced to 1) to prevent X-Forwarded-For spoofing.
|
||||
TRUST_PROXY=1
|
||||
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
|
||||
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
||||
DEBUG_TRUST_PROXY=0
|
||||
|
||||
# Optional TOTP two-factor (opt-in per user).
|
||||
TOTP_ISSUER=UOMysticmoon
|
||||
TOTP_CHALLENGE_TTL=5m
|
||||
|
||||
# First admin bootstrap — created only if no users exist yet.
|
||||
# Set, run once, then you can blank these out.
|
||||
ADMIN_USERNAME=
|
||||
@@ -43,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
|
||||
|
||||
146
README.md
146
README.md
@@ -3,7 +3,7 @@
|
||||
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
|
||||
shard — a full-stack app in one repo:
|
||||
|
||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, JWT-in-cookie auth.
|
||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||
|
||||
@@ -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)
|
||||
@@ -35,10 +36,11 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
||||
| Auth | JWT in an httpOnly cookie, bcrypt password hashing |
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
|
||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||
|
||||
---
|
||||
@@ -51,18 +53,20 @@ UOMSITE/
|
||||
│ ├─ src/
|
||||
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||
│ │ ├─ app.js middleware + static SPA + routes
|
||||
│ │ ├─ router/v1/ auth / public / admin route groups
|
||||
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db)
|
||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · validate
|
||||
│ │ └─ utils/ auth (JWT/cookies) · db (pool) · mailer · logger
|
||||
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
|
||||
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
|
||||
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
|
||||
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
|
||||
│ ├─ db/ schema.sql + seed.js
|
||||
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
|
||||
│ └─ .env.example
|
||||
├─ client/ React + Vite SPA
|
||||
│ ├─ src/
|
||||
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
||||
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
||||
│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users) + editors
|
||||
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, …
|
||||
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
|
||||
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
|
||||
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
||||
│ │ └─ styles/theme.css design tokens
|
||||
@@ -187,7 +191,10 @@ npm start # node server → serves API + SPA at http://localhost:3
|
||||
| `/admin/wiki` | Wiki pages CRUD |
|
||||
| `/admin/settings` | Site settings |
|
||||
| `/admin/activity` | Activity log |
|
||||
| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
|
||||
| `/admin/auth-providers` | Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
|
||||
| `/admin/users` | User management |
|
||||
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,12 +202,52 @@ npm start # node server → serves API + SPA at http://localhost:3
|
||||
|
||||
| Group | Base | Auth |
|
||||
|---|---|---|
|
||||
| Auth | `/api/v1/auth` (`login`, `logout`, `me`) | cookie |
|
||||
| Auth (web) | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie |
|
||||
| Auth (mobile) | `/api/v1/auth/mobile` (`login`, `refresh`, `logout`) | bearer (access + refresh tokens) |
|
||||
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `users`) | cookie (admin) |
|
||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
||||
|
||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
|
||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
||||
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
||||
|
||||
---
|
||||
|
||||
## API documentation (Swagger)
|
||||
|
||||
The full API is documented as an **OpenAPI 3.0** spec and served with **Swagger UI**:
|
||||
|
||||
| URL | What |
|
||||
|---|---|
|
||||
| `http://localhost:3000/api/docs` | Interactive Swagger UI (try-it-out, auth) |
|
||||
| `http://localhost:3000/api/docs.json` | Raw OpenAPI 3.0 spec (JSON) |
|
||||
|
||||
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
|
||||
groups) with its summary, parameters, request body, security requirement, and the response codes it
|
||||
actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `429` rate limits, …).
|
||||
|
||||
**Authentication in the UI** — click **Authorize** and provide either:
|
||||
|
||||
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
|
||||
`POST /api/v1/auth/login`), or
|
||||
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
||||
`Authorization: Bearer <token>`).
|
||||
|
||||
**Regenerating the spec** — the spec is generated from `#swagger.*` annotations next to each route
|
||||
(`server/src/router/**`) plus the shared definitions in `server/swagger/swagger.js`
|
||||
([swagger-autogen](https://github.com/davibaltar/swagger-autogen)). The output
|
||||
`server/swagger/swagger-output.json` is committed so the docs work with no build step. After adding
|
||||
or changing a route, regenerate it:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run swagger # → server/swagger/swagger-output.json
|
||||
```
|
||||
|
||||
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||
not crash).
|
||||
|
||||
---
|
||||
|
||||
@@ -212,13 +259,22 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
|---|---|---|
|
||||
| `NODE_ENV` | `production` | |
|
||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||
| `JWT_SECRET` | — | **required** — long random string |
|
||||
| `JWT_EXPIRES_IN` | `1d` | token + cookie lifetime |
|
||||
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
||||
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
||||
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
||||
| `COOKIE_NAME` | `uomm_token` | |
|
||||
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
|
||||
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
|
||||
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
|
||||
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
|
||||
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
|
||||
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
|
||||
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
|
||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
|
||||
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
|
||||
@@ -230,11 +286,65 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
|
||||
## Security
|
||||
|
||||
JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected) · bcrypt hashing · login &
|
||||
contact rate limiting · `express-validator` on writes · `helmet` · admin routes `noindex` +
|
||||
`robots.txt` disallow · `trust proxy` for correct client IPs behind Pangolin · first admin seeded
|
||||
from env (no hardcoded credentials) · `.env` git-ignored. Passwords and request bodies are never
|
||||
logged. SMTP is optional — the contact form falls back to a `mailto:` link when unconfigured.
|
||||
**Session & authorization**
|
||||
|
||||
- All auth flows go through one **session service** (`server/src/auth/`): controllers call
|
||||
`sessionService.createSession(user, authMethod)` and middleware calls `validateSession()`, so web
|
||||
cookies, mobile bearer tokens, and SSO all produce the *same* authenticated session model.
|
||||
`utils/auth.js` remains a thin backward-compat facade.
|
||||
- JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected), bcrypt password hashing.
|
||||
- Admin routes are **re-validated against the database on every request**, so a demoted or deleted
|
||||
user loses access immediately instead of keeping their old role until the token expires.
|
||||
- **Role-based authorization** — admin-only endpoints (users, site mode, settings, auth providers)
|
||||
are gated by a `requireRole` check, so a lower-privilege editor can't reach them.
|
||||
|
||||
**Mobile bearer auth**
|
||||
|
||||
- Native clients use `/api/v1/auth/mobile/*`: a short-lived **access token** (bearer JWT, validated
|
||||
by the same middleware as the cookie) plus a long-lived, **server-stored, revocable refresh
|
||||
token** that is **rotated on every refresh** (a replayed refresh token is single-use). Refresh
|
||||
tokens are stored **hashed** (never in the clear); logout revokes one or all. Mobile login reuses
|
||||
the same bot-scoring + backoff defenses as web, with single-request TOTP.
|
||||
|
||||
**Single sign-on (OAuth2 / OIDC)**
|
||||
|
||||
- Pluggable providers — built-in **Google** and **Discord** (endpoints fixed in code; admins supply
|
||||
only client id/secret) plus fully-configurable **custom OIDC/OAuth2** providers, managed from the
|
||||
**Authentication** admin panel. Only `enabled` + fully-configured providers are shown to users.
|
||||
- **Link-only** by policy: an SSO login succeeds *only* if the external identity is already linked to
|
||||
an existing account (linked by the user from **Account**). External identities are **never
|
||||
auto-provisioned** — no one gains access without an account you created.
|
||||
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
|
||||
**PKCE**; OAuth client secrets are **encrypted at rest** (AES-256-GCM) and never returned to any
|
||||
client. SSO logins go through the same `sessionService`, so login/activity logging, RBAC, and bot
|
||||
protection are identical to a local login.
|
||||
|
||||
**Login hardening**
|
||||
|
||||
- **Optional per-user TOTP two-factor** (opt-in, self-service on `/admin/account`). When enabled,
|
||||
the password step issues only a short-lived, non-session `stage:'totp'` challenge; a session
|
||||
cookie is granted only after the second factor verifies.
|
||||
- **Login throttling** — `express-slow-down` + a hard rate cap + a separate per-IP exponential
|
||||
backoff, with generic error messages that don't reveal whether the username exists.
|
||||
- **Honeypot** field on the login form; submissions that fill it are treated as bots.
|
||||
- **Bot-scoring + automatic IP ban** — weighted scoring of CMS-scanner paths and junk 404s (with a
|
||||
periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the
|
||||
score. Admins get visibility into this on the **Bot Activity** panel: currently banned IPs and a
|
||||
recent-events feed (in-memory, most-recent-first), plus a logged emergency **unban** for false
|
||||
positives — read + unban only, not a scoring-config surface.
|
||||
|
||||
**Uploads & input**
|
||||
|
||||
- Uploaded file extensions are derived from the **validated mimetype**, not the client-supplied
|
||||
filename (prevents a disguised-extension upload).
|
||||
- `express-validator` on all writes; usernames are validated **and** uniqueness-checked on update.
|
||||
|
||||
**Platform**
|
||||
|
||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||
behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. SMTP is optional — the contact
|
||||
form falls back to a `mailto:` link when unconfigured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
45
bot/.env.example
Normal file
45
bot/.env.example
Normal file
@@ -0,0 +1,45 @@
|
||||
# ─── UOMysticmoon Discord bot — local dev environment ───
|
||||
# Copy to bot/.env for running `npm run dev` outside Docker.
|
||||
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||
#
|
||||
# NOTE: there is no Discord bot token here on purpose. The token is entered
|
||||
# in the admin panel (Discord Bot page), stored encrypted in the main site's
|
||||
# DB, and pushed to this process in-memory over the internal API. It is
|
||||
# never read from an env var and never written to this process's disk.
|
||||
|
||||
PORT=4100
|
||||
|
||||
# Logging — written to BOTH the console and a log file (default <bot>/logs/bot.log).
|
||||
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
|
||||
FILE_LOG_LEVEL=debug # file verbosity
|
||||
LOG_TO_FILE=true # set false for console-only
|
||||
# LOG_DIR= # defaults to bot/logs
|
||||
# LOG_FILE=bot.log
|
||||
|
||||
# Shared secret for the internal API between this bot and the main site
|
||||
# (server/). MUST be byte-for-byte identical to BOT_INTERNAL_KEY in
|
||||
# server/.env.example / the root .env.example — it is the only auth on both
|
||||
# sides' /internal/* routes, so a mismatch silently breaks every server<->bot
|
||||
# call with 401s. Generate one long random string and copy it to both places.
|
||||
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||
|
||||
# Where this bot calls back to the main site to fetch its config on boot
|
||||
# (GET .../internal/bot-config), so a restart self-reconnects without needing
|
||||
# the admin panel to push config again. This targets the site's UNPUBLISHED
|
||||
# internal port (INTERNAL_PORT, default 3001) — NOT the public 3000. See #33.
|
||||
SITE_INTERNAL_URL=http://localhost:3001/internal/bot-config
|
||||
|
||||
# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any
|
||||
# visitor's browser can fetch. Used by /wiki (search) and /announce
|
||||
# (re-post an existing news item).
|
||||
SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
|
||||
|
||||
# Database (Phase 2+) — same physical DB as the main site, but the bot only
|
||||
# ever reads/writes its OWN tables (guild_config, mod_actions, warnings, and
|
||||
# more in later phases). It never touches site tables (users, bot_config,
|
||||
# etc.) directly. Point this at the same DB the server/ uses.
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=uomysticmoon
|
||||
DB_USER=uomm
|
||||
DB_PASSWORD=change-me-db-password
|
||||
3
bot/.gitignore
vendored
Normal file
3
bot/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
logs/
|
||||
16
bot/Dockerfile
Normal file
16
bot/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app/bot
|
||||
|
||||
COPY bot/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY bot/ .
|
||||
|
||||
RUN mkdir -p /app/bot/logs && chown -R node:node /app/bot/logs
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4100
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
1609
bot/package-lock.json
generated
Normal file
1609
bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
bot/package.json
Normal file
24
bot/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "uomysticmoon-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord bot for the UOMysticmoon community server",
|
||||
"private": true,
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"keywords": ["discord", "discord.js"],
|
||||
"author": "whitlocktech",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"mariadb": "^3.3.1",
|
||||
"node-cron": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4"
|
||||
}
|
||||
}
|
||||
12
bot/src/app.js
Normal file
12
bot/src/app.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express')
|
||||
|
||||
const internalRouter = require('./internal/internal.routes')
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok' }))
|
||||
app.use('/internal', internalRouter)
|
||||
|
||||
module.exports = app
|
||||
38
bot/src/bootstrap.js
vendored
Normal file
38
bot/src/bootstrap.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
// Runs once at process start, before the internal Express server is
|
||||
// considered ready. Fetches current config from the main site (token,
|
||||
// guildId, enabled) and reconnects immediately if enabled — so a bot
|
||||
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
||||
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
||||
// extra HTTP client dependency needed for a single startup call.
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
const log = createLogger('bootstrap')
|
||||
|
||||
async function bootstrap() {
|
||||
const siteUrl = process.env.SITE_INTERNAL_URL
|
||||
const key = process.env.BOT_INTERNAL_KEY
|
||||
if (!siteUrl || !key) {
|
||||
log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||
if (!res.ok) {
|
||||
log.error('boot-time config fetch failed', { status: res.status })
|
||||
return
|
||||
}
|
||||
const config = await res.json()
|
||||
if (config.enabled) {
|
||||
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||
await discordManager.start({ token: config.token, guildId: config.guildId })
|
||||
} else {
|
||||
log.info('boot-time config says disabled — staying disconnected')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('boot-time config fetch errored', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = bootstrap
|
||||
41
bot/src/db.js
Normal file
41
bot/src/db.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) —
|
||||
// mirrors server/src/utils/db.js. The bot never reads/writes any table it
|
||||
// doesn't own; site-owned tables (users, bot_config, etc.) are reached only
|
||||
// through the internal API, never directly. Schema for these tables lives in
|
||||
// server/db/schema.sql (same physical database, ensured by the main server on
|
||||
// boot) — there's no separate migration tool to justify a second database for
|
||||
// a single-guild v1 bot.
|
||||
const mariadb = require('mariadb')
|
||||
|
||||
const pool = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'uomysticmoon',
|
||||
connectionLimit: 5,
|
||||
insertIdAsNumber: true,
|
||||
bigIntAsNumber: true,
|
||||
decimalAsNumber: true,
|
||||
// The driver defaults to 'local' — silently serializing bound JS Date
|
||||
// params using the HOST MACHINE's local offset instead of the DB session's
|
||||
// timezone (discovered via temp_roles.expires_at coming back hours off in
|
||||
// dev, CDT vs the container's UTC). 'auto' negotiates the actual session
|
||||
// timezone so Date round-trips correctly regardless of host TZ.
|
||||
timezone: 'auto',
|
||||
})
|
||||
|
||||
async function query(sql, params) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
return await conn.query(sql, params)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function close() {
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
module.exports = { query, close }
|
||||
49
bot/src/discord/commands/announce.command.js
Normal file
49
bot/src/discord/commands/announce.command.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const siteApiClient = require('../../site/siteApiClient')
|
||||
const newsAnnounce = require('../newsAnnounce')
|
||||
|
||||
function siteOrigin() {
|
||||
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||
return new URL(base).origin
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'announce',
|
||||
description: 'Re-post or boost an existing news item.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{ name: 'post', description: 'News post id or slug', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const idOrSlug = interaction.options.getString('post', true)
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
|
||||
const result = await siteApiClient.getNewsPost(idOrSlug)
|
||||
if (result.maintenance) {
|
||||
await interaction.editReply({ content: `Can't reach the site right now: ${result.message || 'maintenance mode'}` })
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
await interaction.editReply({ content: `Couldn't find that news post ("${idOrSlug}").` })
|
||||
return
|
||||
}
|
||||
|
||||
const post = result.data
|
||||
const origin = siteOrigin()
|
||||
try {
|
||||
await newsAnnounce.postAnnounce(interaction.client, interaction.guildId, {
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${origin}/site/news`,
|
||||
// image_url is stored relative — Discord embeds require an absolute URL.
|
||||
imageUrl: post.image_url ? new URL(post.image_url, origin).toString() : null,
|
||||
})
|
||||
await interaction.editReply({ content: `Posted "${post.title}" to the news channel.` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't post: ${err.message}` })
|
||||
}
|
||||
},
|
||||
}
|
||||
30
bot/src/discord/commands/autorole.command.js
Normal file
30
bot/src/discord/commands/autorole.command.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'autorole',
|
||||
description: 'View or set the role automatically assigned to new members on join.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'role',
|
||||
description: 'Role to auto-assign on join. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Role,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const role = interaction.options.getRole('role')
|
||||
if (!role) {
|
||||
const currentId = await guildConfig.getAutoRoleId(interaction.guildId)
|
||||
const content = currentId ? `Auto-role is set to <@&${currentId}>.` : 'No auto-role is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setAutoRoleId(interaction.guildId, role.id)
|
||||
await interaction.reply({ content: `Auto-role set to ${role}. New members will get this automatically.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
34
bot/src/discord/commands/ban.command.js
Normal file
34
bot/src/discord/commands/ban.command.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'ban',
|
||||
description: 'Ban a member from the server.',
|
||||
default_member_permissions: PermissionFlagsBits.BanMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to ban', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the ban', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't ban yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (member && !member.bannable) {
|
||||
await interaction.reply({ content: "I don't have permission to ban that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await interaction.guild.members.ban(user, { reason })
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'ban', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Banned ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
73
bot/src/discord/commands/filter.command.js
Normal file
73
bot/src/discord/commands/filter.command.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const filterWords = require('../../model/filterWords')
|
||||
const filterCache = require('../../filter/filterCache')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'filter',
|
||||
description: 'Manage the banned-word filter.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add a word to the filter.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'word', description: 'Word or phrase to ban', type: ApplicationCommandOptionType.String, required: true },
|
||||
{
|
||||
name: 'severity',
|
||||
description: 'Auto-action when triggered (default: delete)',
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: false,
|
||||
choices: [
|
||||
{ name: 'Delete only', value: 'delete' },
|
||||
{ name: 'Delete + warn', value: 'warn' },
|
||||
{ name: 'Delete + mute (10m)', value: 'mute' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a word from the filter.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'word', description: 'Word or phrase to remove', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all filtered words.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'add') {
|
||||
const word = interaction.options.getString('word', true)
|
||||
const severity = interaction.options.getString('severity') || 'delete'
|
||||
await filterWords.add({ guildId: interaction.guildId, word, severity, addedBy: interaction.user.id, addedByTag: interaction.user.tag })
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `Added "${word}" to the filter (${severity}).`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const word = interaction.options.getString('word', true)
|
||||
const removed = await filterWords.remove(interaction.guildId, word)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: removed ? `Removed "${word}" from the filter.` : `"${word}" wasn't in the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const words = await filterWords.list(interaction.guildId)
|
||||
const content = words.length === 0 ? 'The filter list is empty.' : words.map((w) => `${w.word} (${w.severity})`).join('\n')
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
61
bot/src/discord/commands/filterallow.command.js
Normal file
61
bot/src/discord/commands/filterallow.command.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const filterAllowlist = require('../../model/filterAllowlist')
|
||||
const filterCache = require('../../filter/filterCache')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'filterallow',
|
||||
description: 'Manage roles/channels that bypass the filter entirely.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'role',
|
||||
description: 'Toggle a role in/out of the filter bypass list.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'role', description: 'Role to toggle', type: ApplicationCommandOptionType.Role, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Toggle a channel in/out of the filter bypass list.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'channel', description: 'Channel to toggle', type: ApplicationCommandOptionType.Channel, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'Show current filter bypass roles/channels.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'role') {
|
||||
const role = interaction.options.getRole('role', true)
|
||||
const nowAllowed = await filterAllowlist.toggleRole(interaction.guildId, role.id)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `${role} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'channel') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const nowAllowed = await filterAllowlist.toggleChannel(interaction.guildId, channel.id)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `${channel} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const [roles, channels] = await Promise.all([
|
||||
filterAllowlist.getRoles(interaction.guildId),
|
||||
filterAllowlist.getChannels(interaction.guildId),
|
||||
])
|
||||
const roleText = roles.length ? roles.map((id) => `<@&${id}>`).join(', ') : 'none'
|
||||
const channelText = channels.length ? channels.map((id) => `<#${id}>`).join(', ') : 'none'
|
||||
await interaction.reply({ content: `Bypass roles: ${roleText}\nBypass channels: ${channelText}`, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
31
bot/src/discord/commands/index.js
Normal file
31
bot/src/discord/commands/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Command registry. Each module exports { data, execute } — `data` is the
|
||||
// slash-command definition pushed to Discord (registerCommands), `execute` is
|
||||
// the interactionCreate handler (dispatch). Adding a new command is just
|
||||
// adding a file here — discordManager.js never needs to change.
|
||||
const commands = [
|
||||
require('./ping.command'),
|
||||
require('./modlog.command'),
|
||||
require('./ban.command'),
|
||||
require('./kick.command'),
|
||||
require('./mute.command'),
|
||||
require('./warn.command'),
|
||||
require('./warnings.command'),
|
||||
require('./filter.command'),
|
||||
require('./filterallow.command'),
|
||||
require('./schedule.command'),
|
||||
require('./rolemenu.command'),
|
||||
require('./autorole.command'),
|
||||
require('./role.command'),
|
||||
require('./roles.command'),
|
||||
require('./invite.command'),
|
||||
require('./news.command'),
|
||||
require('./announce.command'),
|
||||
require('./wiki.command'),
|
||||
]
|
||||
|
||||
const byName = new Map(commands.map((c) => [c.data.name, c]))
|
||||
|
||||
module.exports = {
|
||||
all: commands,
|
||||
get: (name) => byName.get(name),
|
||||
}
|
||||
85
bot/src/discord/commands/invite.command.js
Normal file
85
bot/src/discord/commands/invite.command.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
const inviteLog = require('../../model/inviteLog')
|
||||
const inviteRotator = require('../../invites/inviteRotator')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'invite',
|
||||
description: 'Manage the auto-rotating primary server invite.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'View or set the channel new invites are created in.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel to create invites in. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'rotate',
|
||||
description: 'Revoke the current invite and generate a new one now.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
name: 'log',
|
||||
description: 'Show recent invite rotation history.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'channel') {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
|
||||
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'rotate') {
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
try {
|
||||
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
|
||||
triggeredBy: interaction.user.id,
|
||||
triggeredByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'log') {
|
||||
const rows = await inviteLog.list(interaction.guildId, 10)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const who = r.triggered_by_tag || 'automatic (scheduled)'
|
||||
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
|
||||
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
38
bot/src/discord/commands/kick.command.js
Normal file
38
bot/src/discord/commands/kick.command.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'kick',
|
||||
description: 'Kick a member from the server.',
|
||||
default_member_permissions: PermissionFlagsBits.KickMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to kick', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the kick', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't kick yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
if (!member.kickable) {
|
||||
await interaction.reply({ content: "I don't have permission to kick that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await member.kick(reason)
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'kick', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Kicked ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
33
bot/src/discord/commands/modlog.command.js
Normal file
33
bot/src/discord/commands/modlog.command.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'modlog',
|
||||
description: 'View or set the mod-log channel (ban/kick/mute/warn actions post here).',
|
||||
// Configuration, not a moderation action — gated to Manage Server rather
|
||||
// than the ModerateMembers bit the action commands use.
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel to post mod-log entries to. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getModLogChannelId(interaction.guildId)
|
||||
const content = currentId ? `Mod-log channel is set to <#${currentId}>.` : 'No mod-log channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setModLogChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Mod-log channel set to ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
49
bot/src/discord/commands/mute.command.js
Normal file
49
bot/src/discord/commands/mute.command.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
const { parseDuration, MAX_TIMEOUT_MS } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'mute',
|
||||
description: 'Timeout a member for a duration (e.g. 10m, 2h, 1d).',
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to mute', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'duration', description: 'e.g. 30s, 10m, 2h, 1d (max 28d)', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'reason', description: 'Reason for the mute', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const durationInput = interaction.options.getString('duration', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't mute yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const ms = parseDuration(durationInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid duration — use a number plus s/m/h/d, e.g. `10m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const clampedMs = Math.min(ms, MAX_TIMEOUT_MS)
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
if (!member.moderatable) {
|
||||
await interaction.reply({ content: "I don't have permission to timeout that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await member.timeout(clampedMs, reason)
|
||||
const durationSeconds = Math.round(clampedMs / 1000)
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'mute', target: user, staffUser: interaction.user, reason, durationSeconds })
|
||||
await interaction.reply({ content: `Muted ${user.tag} for ${durationInput}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
31
bot/src/discord/commands/news.command.js
Normal file
31
bot/src/discord/commands/news.command.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'news',
|
||||
description: 'View or set the channel news posts are announced to.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel for news announcements. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getNewsChannelId(interaction.guildId)
|
||||
const content = currentId ? `News channel is set to <#${currentId}>.` : 'No news channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setNewsChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `News channel set to ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
15
bot/src/discord/commands/ping.command.js
Normal file
15
bot/src/discord/commands/ping.command.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { PermissionFlagsBits } = require('discord.js')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'ping',
|
||||
description: 'Health-check — replies pong if the bot is alive and staff-permitted.',
|
||||
// Restricted by default to members with Moderate Members — proves slash
|
||||
// commands can be permission-gated via Discord's own permission model,
|
||||
// per the spec's "restrict staff commands via Discord's permission system".
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
},
|
||||
async execute(interaction) {
|
||||
await interaction.reply({ content: 'pong', ephemeral: true })
|
||||
},
|
||||
}
|
||||
71
bot/src/discord/commands/role.command.js
Normal file
71
bot/src/discord/commands/role.command.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const tempRoles = require('../../model/tempRoles')
|
||||
const { parseDuration } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'role',
|
||||
description: 'Assign or remove a role for a single member.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add a role to a member, optionally temporary.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'role', description: 'Role to add', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'duration', description: 'Optional — makes this temporary, e.g. 1h, 2d, 7d', type: ApplicationCommandOptionType.String, required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a role from a member.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'role', description: 'Role to remove', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const role = interaction.options.getRole('role', true)
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
await member.roles.add(role.id)
|
||||
const durationInput = interaction.options.getString('duration')
|
||||
if (!durationInput) {
|
||||
await interaction.reply({ content: `Added ${role} to ${user.tag}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const ms = parseDuration(durationInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({
|
||||
content: `Added ${role}, but "${durationInput}" isn't a valid duration so it won't expire automatically. Use e.g. 1h, 2d, 7d.`,
|
||||
ephemeral: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
const expiresAt = new Date(Date.now() + ms)
|
||||
await tempRoles.add({ guildId: interaction.guildId, userId: user.id, roleId: role.id, expiresAt, createdBy: interaction.user.id })
|
||||
await interaction.reply({ content: `Added ${role} to ${user.tag} until ${expiresAt.toLocaleString()}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
await member.roles.remove(role.id)
|
||||
await tempRoles.remove(interaction.guildId, user.id, role.id)
|
||||
await interaction.reply({ content: `Removed ${role} from ${user.tag}.`, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
85
bot/src/discord/commands/rolemenu.command.js
Normal file
85
bot/src/discord/commands/rolemenu.command.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const {
|
||||
PermissionFlagsBits,
|
||||
ApplicationCommandOptionType,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
} = require('discord.js')
|
||||
|
||||
const roleMenus = require('../../model/roleMenus')
|
||||
|
||||
// Capped at 5 roles per menu — a single Discord action row holds at most 5
|
||||
// buttons, and one row keeps this a single simple slash command instead of
|
||||
// needing a multi-step builder/modal flow.
|
||||
const MAX_ROLES = 5
|
||||
|
||||
// role1/label1 are declared inline in `data` (ahead of the optional
|
||||
// `description` option, per Discord's required-before-optional rule) — this
|
||||
// generates the rest, all optional.
|
||||
function roleOptions(from, to) {
|
||||
const opts = []
|
||||
for (let i = from; i <= to; i++) {
|
||||
opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false })
|
||||
opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false })
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'rolemenu',
|
||||
description: 'Post a button menu for self-assignable roles (up to 5).',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
// Discord requires all required options before any optional ones across
|
||||
// the whole array — role1 (required) must come before description
|
||||
// (optional), even though they read more naturally in the other order.
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false },
|
||||
{ name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false },
|
||||
...roleOptions(2, MAX_ROLES),
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const title = interaction.options.getString('title', true)
|
||||
const description = interaction.options.getString('description') || undefined
|
||||
|
||||
const entries = []
|
||||
for (let i = 1; i <= MAX_ROLES; i++) {
|
||||
const role = interaction.options.getRole(`role${i}`)
|
||||
if (!role) continue
|
||||
const label = interaction.options.getString(`label${i}`) || role.name
|
||||
entries.push({ roleId: role.id, label })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
|
||||
if (description) embed.setDescription(description)
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
entries.map((e) =>
|
||||
new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary),
|
||||
),
|
||||
)
|
||||
|
||||
const message = await channel.send({ embeds: [embed], components: [row] })
|
||||
await roleMenus.add({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
messageId: message.id,
|
||||
mapping: entries,
|
||||
createdBy: interaction.user.id,
|
||||
})
|
||||
|
||||
await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
68
bot/src/discord/commands/roles.command.js
Normal file
68
bot/src/discord/commands/roles.command.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
// Bulk targeting is "by existing role" only — the spec also mentions an
|
||||
// explicit list of members, but Discord slash commands have no multi-user
|
||||
// picker, so that variant is deferred rather than faked with a handful of
|
||||
// user1..user5 options that would feel arbitrary and cramped.
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'roles',
|
||||
description: 'Bulk role operations across members who share an existing role.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'bulk-assign',
|
||||
description: 'Add a role to every member who has another role.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'add-role', description: 'Role to add to those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'bulk-remove',
|
||||
description: 'Remove a role from every member who has another role.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'remove-role', description: 'Role to remove from those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
// Fetching every member + looping role updates can easily exceed
|
||||
// Discord's 3-second initial-response window.
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
|
||||
const hasRole = interaction.options.getRole('has-role', true)
|
||||
const members = await interaction.guild.members.fetch()
|
||||
const targets = members.filter((m) => m.roles.cache.has(hasRole.id))
|
||||
|
||||
if (sub === 'bulk-assign') {
|
||||
const addRole = interaction.options.getRole('add-role', true)
|
||||
let count = 0
|
||||
for (const member of targets.values()) {
|
||||
if (!member.roles.cache.has(addRole.id)) {
|
||||
await member.roles.add(addRole.id).catch(() => {})
|
||||
count++
|
||||
}
|
||||
}
|
||||
await interaction.editReply({ content: `Added ${addRole} to ${count} member(s) who have ${hasRole}.` })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'bulk-remove') {
|
||||
const removeRole = interaction.options.getRole('remove-role', true)
|
||||
let count = 0
|
||||
for (const member of targets.values()) {
|
||||
if (member.roles.cache.has(removeRole.id)) {
|
||||
await member.roles.remove(removeRole.id).catch(() => {})
|
||||
count++
|
||||
}
|
||||
}
|
||||
await interaction.editReply({ content: `Removed ${removeRole} from ${count} member(s) who have ${hasRole}.` })
|
||||
}
|
||||
},
|
||||
}
|
||||
119
bot/src/discord/commands/schedule.command.js
Normal file
119
bot/src/discord/commands/schedule.command.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
const cron = require('node-cron')
|
||||
|
||||
const scheduledMessages = require('../../model/scheduledMessages')
|
||||
const scheduler = require('../../scheduler/scheduler')
|
||||
const { parseDuration } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'schedule',
|
||||
description: 'Manage recurring and one-off scheduled channel messages.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'recurring',
|
||||
description: 'Schedule a recurring message on a cron schedule.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'once',
|
||||
description: 'Schedule a one-off message for a future time.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a scheduled message by id.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all scheduled messages.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'recurring') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const cronExpr = interaction.options.getString('cron', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
if (!cron.validate(cronExpr)) {
|
||||
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const id = await scheduledMessages.addRecurring({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
cronExpression: cronExpr,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'once') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const inInput = interaction.options.getString('in', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
const ms = parseDuration(inInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const runAt = new Date(Date.now() + ms)
|
||||
const id = await scheduledMessages.addOnce({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
runAt,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const id = interaction.options.getInteger('id', true)
|
||||
const removed = await scheduledMessages.remove(interaction.guildId, id)
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const rows = await scheduledMessages.list(interaction.guildId)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const kind = r.cron_expression
|
||||
? `cron \`${r.cron_expression}\``
|
||||
: r.sent_at
|
||||
? `sent ${new Date(r.sent_at).toLocaleString()}`
|
||||
: `due ${new Date(r.run_at).toLocaleString()}`
|
||||
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
40
bot/src/discord/commands/warn.command.js
Normal file
40
bot/src/discord/commands/warn.command.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
const warnings = require('../../model/warnings')
|
||||
|
||||
// Escalation (e.g. "3 active warns -> auto-mute for X hours") and warning
|
||||
// decay/expiry are in the original spec but deferred past this phase — this
|
||||
// just records the warning and posts it to the mod-log, matching the
|
||||
// "Suggested Build Order" step 2 scope (core moderation).
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'warn',
|
||||
description: 'Log a warning against a member.',
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to warn', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the warning', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't warn yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await warnings.add({
|
||||
guildId: interaction.guildId,
|
||||
targetUserId: user.id,
|
||||
targetTag: user.tag,
|
||||
staffUserId: interaction.user.id,
|
||||
staffTag: interaction.user.tag,
|
||||
reason,
|
||||
})
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'warn', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Warned ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
34
bot/src/discord/commands/warnings.command.js
Normal file
34
bot/src/discord/commands/warnings.command.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, EmbedBuilder } = require('discord.js')
|
||||
|
||||
const warnings = require('../../model/warnings')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'warnings',
|
||||
description: "List a member's active warnings.",
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to look up', type: ApplicationCommandOptionType.User, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const rows = await warnings.listActive(interaction.guildId, user.id)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: `${user.tag} has no active warnings.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0xe0b070)
|
||||
.setTitle(`Warnings — ${user.tag}`)
|
||||
.setDescription(
|
||||
rows
|
||||
.map((w, i) => `**${i + 1}.** ${w.reason || '(no reason given)'} — by ${w.staff_tag || 'unknown'} on ${new Date(w.created_at).toLocaleDateString()}`)
|
||||
.join('\n'),
|
||||
)
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true })
|
||||
},
|
||||
}
|
||||
40
bot/src/discord/commands/wiki.command.js
Normal file
40
bot/src/discord/commands/wiki.command.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const siteApiClient = require('../../site/siteApiClient')
|
||||
|
||||
// Public command — no default_member_permissions restriction. Read-only:
|
||||
// searches wiki titles/content and links to the best match. Never posts to or
|
||||
// edits the wiki. Category-scoped search (spec's optional "/wiki spells
|
||||
// fireball") is deferred — the site's public search endpoint currently
|
||||
// ignores category filters whenever a text query is given.
|
||||
function siteOrigin() {
|
||||
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||
return new URL(base).origin
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'wiki',
|
||||
description: 'Search the wiki.',
|
||||
options: [{ name: 'query', description: 'What to search for', type: ApplicationCommandOptionType.String, required: true }],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const query = interaction.options.getString('query', true)
|
||||
await interaction.deferReply()
|
||||
|
||||
const result = await siteApiClient.searchWiki(query)
|
||||
if (result.maintenance) {
|
||||
await interaction.editReply({ content: `The wiki is unavailable right now: ${result.message || 'maintenance mode'}` })
|
||||
return
|
||||
}
|
||||
if (!result.ok || !result.data || result.data.length === 0) {
|
||||
await interaction.editReply({ content: `No wiki results for "${query}".` })
|
||||
return
|
||||
}
|
||||
|
||||
const best = result.data[0]
|
||||
const url = `${siteOrigin()}/wiki/${best.slug}`
|
||||
const content = best.excerpt ? `**${best.title}**\n${best.excerpt}\n${url}` : `**${best.title}**\n${url}`
|
||||
await interaction.editReply({ content })
|
||||
},
|
||||
}
|
||||
144
bot/src/discord/discordManager.js
Normal file
144
bot/src/discord/discordManager.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// Owns the single discord.js Client instance for this process: lifecycle
|
||||
// (start/stop/status) and slash-command registration/dispatch. Command
|
||||
// definitions themselves live in ./commands — this file only wires them up.
|
||||
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
|
||||
|
||||
const createLogger = require('../utils/logger')
|
||||
const commands = require('./commands')
|
||||
const messageFilter = require('./messageFilter')
|
||||
const scheduler = require('../scheduler/scheduler')
|
||||
const roleMenuHandler = require('./roleMenuHandler')
|
||||
const { handleGuildMemberAdd } = require('./guildMemberAdd')
|
||||
const { handleGuildMemberRemove } = require('./guildMemberRemove')
|
||||
const inviteTracker = require('./inviteTracker')
|
||||
const tempRoleSweeper = require('../roles/tempRoleSweeper')
|
||||
const inviteScheduler = require('../invites/inviteScheduler')
|
||||
|
||||
const log = createLogger('discord')
|
||||
|
||||
let client = null
|
||||
let guildId = null
|
||||
let status = 'disconnected' // disconnected | connecting | connected | error
|
||||
let statusDetail = null
|
||||
let lastConnectedAt = null
|
||||
|
||||
async function registerCommands(applicationId, targetGuildId) {
|
||||
const rest = new REST({ version: '10' }).setToken(client.token)
|
||||
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
|
||||
body: commands.all.map((c) => c.data),
|
||||
})
|
||||
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!client) {
|
||||
status = 'disconnected'
|
||||
statusDetail = null
|
||||
return
|
||||
}
|
||||
scheduler.stop()
|
||||
tempRoleSweeper.stop()
|
||||
inviteScheduler.stop()
|
||||
try {
|
||||
await client.destroy()
|
||||
} catch (err) {
|
||||
log.warn('error while destroying client', { message: err.message })
|
||||
}
|
||||
client = null
|
||||
status = 'disconnected'
|
||||
statusDetail = null
|
||||
log.info('discord client disconnected')
|
||||
}
|
||||
|
||||
// start({ token, guildId }) — (re)connects. Always stops any existing client
|
||||
// first so re-saving config or toggling Enabled off/on is idempotent.
|
||||
async function start({ token, guildId: gid }) {
|
||||
await stop()
|
||||
guildId = gid
|
||||
status = 'connecting'
|
||||
statusDetail = null
|
||||
|
||||
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
|
||||
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
|
||||
// in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
|
||||
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
|
||||
client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildInvites,
|
||||
],
|
||||
})
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
await registerCommands(client.application.id, guildId)
|
||||
await scheduler.start(client)
|
||||
tempRoleSweeper.start(client)
|
||||
inviteScheduler.start(client, guildId)
|
||||
await inviteTracker.prime(client, guildId)
|
||||
status = 'connected'
|
||||
statusDetail = null
|
||||
lastConnectedAt = new Date()
|
||||
log.info('discord client ready', { user: client.user?.tag, guildId })
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = `startup failed: ${err.message}`
|
||||
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
client.on('interactionCreate', async (interaction) => {
|
||||
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const command = commands.get(interaction.commandName)
|
||||
if (!command) return
|
||||
try {
|
||||
await command.execute(interaction)
|
||||
} catch (err) {
|
||||
log.error('command execution failed', { command: interaction.commandName, message: err.message })
|
||||
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
|
||||
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
|
||||
else await interaction.reply(payload)
|
||||
}
|
||||
})
|
||||
|
||||
client.on('messageCreate', messageFilter.handleMessageCreate)
|
||||
client.on('guildMemberAdd', handleGuildMemberAdd)
|
||||
client.on('guildMemberRemove', handleGuildMemberRemove)
|
||||
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
|
||||
client.on('inviteCreate', inviteTracker.onInviteCreate)
|
||||
client.on('inviteDelete', inviteTracker.onInviteDelete)
|
||||
|
||||
client.on('error', (err) => {
|
||||
status = 'error'
|
||||
statusDetail = err.message
|
||||
log.error('discord client error', { message: err.message })
|
||||
})
|
||||
|
||||
try {
|
||||
await client.login(token)
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = err.message
|
||||
client = null
|
||||
log.error('discord login failed', { message: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return { status, statusDetail, guildId, lastConnectedAt }
|
||||
}
|
||||
|
||||
// For code that needs the live client + which guild it's connected to (the
|
||||
// /internal/announce handler, slash commands already get both from the
|
||||
// interaction itself so they don't need this). Returns null if disconnected.
|
||||
function getConnection() {
|
||||
if (!client || status !== 'connected') return null
|
||||
return { client, guildId }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getStatus, getConnection }
|
||||
45
bot/src/discord/guildMemberAdd.js
Normal file
45
bot/src/discord/guildMemberAdd.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Member join handling: record the join event (with best-effort invite
|
||||
// attribution, Phase 6b) then apply the configured auto-role. Requires the
|
||||
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
|
||||
// and, for invite attribution, the GuildInvites intent.
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const memberEvents = require('../model/memberEvents')
|
||||
const inviteTracker = require('./inviteTracker')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('members')
|
||||
|
||||
async function handleGuildMemberAdd(member) {
|
||||
// Attribute the invite first (diffs the invite-use cache), then record the join.
|
||||
// Both are best-effort — a failure here must never block the auto-role below.
|
||||
let invite = { code: null, inviterId: null, inviterTag: null }
|
||||
try {
|
||||
invite = await inviteTracker.attribute(member)
|
||||
} catch (err) {
|
||||
log.warn('invite attribution threw', { userId: member.id, message: err.message })
|
||||
}
|
||||
try {
|
||||
await memberEvents.record({
|
||||
guildId: member.guild.id,
|
||||
eventType: 'join',
|
||||
discordUserId: member.id,
|
||||
username: member.user?.tag,
|
||||
inviteCode: invite.code,
|
||||
inviterId: invite.inviterId,
|
||||
inviterTag: invite.inviterTag,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('member join record failed', { userId: member.id, message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
|
||||
if (!roleId) return
|
||||
await member.roles.add(roleId)
|
||||
log.info('auto-role assigned', { userId: member.id, roleId })
|
||||
} catch (err) {
|
||||
log.warn('auto-role assignment failed', { userId: member.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleGuildMemberAdd }
|
||||
23
bot/src/discord/guildMemberRemove.js
Normal file
23
bot/src/discord/guildMemberRemove.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Member leave handling (Phase 6b): record a leave event for the dashboard's
|
||||
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
|
||||
// distinguish them on this event, and the mod-action (if any) is logged
|
||||
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
|
||||
const memberEvents = require('../model/memberEvents')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('members')
|
||||
|
||||
async function handleGuildMemberRemove(member) {
|
||||
try {
|
||||
await memberEvents.record({
|
||||
guildId: member.guild.id,
|
||||
eventType: 'leave',
|
||||
discordUserId: member.id,
|
||||
username: member.user?.tag,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('member leave record failed', { userId: member.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleGuildMemberRemove }
|
||||
74
bot/src/discord/inviteTracker.js
Normal file
74
bot/src/discord/inviteTracker.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
|
||||
// which invite a member used, so the standard approach is to keep a cache of
|
||||
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
|
||||
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
|
||||
// already creates/deletes invites, so it has the permission). All calls are
|
||||
// best-effort: any failure just yields a null attribution and the join is still
|
||||
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
// guildId -> Map<inviteCode, uses>
|
||||
const cache = new Map()
|
||||
|
||||
async function snapshot(guild) {
|
||||
const map = new Map()
|
||||
const invites = await guild.invites.fetch()
|
||||
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
|
||||
return map
|
||||
}
|
||||
|
||||
// Populate the cache for a guild (call once the client is ready).
|
||||
async function prime(client, guildId) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
|
||||
cache.set(guildId, await snapshot(guild))
|
||||
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
|
||||
} catch (err) {
|
||||
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function onInviteCreate(invite) {
|
||||
if (!invite.guild) return
|
||||
const g = cache.get(invite.guild.id) || new Map()
|
||||
g.set(invite.code, invite.uses || 0)
|
||||
cache.set(invite.guild.id, g)
|
||||
}
|
||||
|
||||
function onInviteDelete(invite) {
|
||||
if (!invite.guild) return
|
||||
const g = cache.get(invite.guild.id)
|
||||
if (g) g.delete(invite.code)
|
||||
}
|
||||
|
||||
// Diff current invite uses against the cached snapshot to find which invite the
|
||||
// joining member used, then refresh the cache. Returns { code, inviterId,
|
||||
// inviterTag } with nulls when it can't be determined.
|
||||
async function attribute(member) {
|
||||
const empty = { code: null, inviterId: null, inviterTag: null }
|
||||
try {
|
||||
const guild = member.guild
|
||||
const before = cache.get(guild.id) || new Map()
|
||||
const current = await guild.invites.fetch()
|
||||
|
||||
let found = empty
|
||||
for (const inv of current.values()) {
|
||||
const prev = before.get(inv.code) || 0
|
||||
if ((inv.uses || 0) > prev && found === empty) {
|
||||
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
|
||||
}
|
||||
}
|
||||
|
||||
const next = new Map()
|
||||
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
|
||||
cache.set(guild.id, next)
|
||||
return found
|
||||
} catch (err) {
|
||||
log.warn('invite attribution failed', { message: err.message })
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }
|
||||
137
bot/src/discord/messageFilter.js
Normal file
137
bot/src/discord/messageFilter.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// messageCreate orchestration: allowlist bypass -> invite link -> banned word
|
||||
// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn
|
||||
// (no severity tiers for those, unlike the word filter) — kept simple per the
|
||||
// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute
|
||||
// duration; per-severity-configurable durations are a future refinement.
|
||||
const filterCache = require('../filter/filterCache')
|
||||
const { findMatch } = require('../filter/normalize')
|
||||
const inviteFilter = require('../filter/inviteFilter')
|
||||
const spamFilter = require('../filter/spamFilter')
|
||||
const warnings = require('../model/warnings')
|
||||
const filterHits = require('../model/filterHits')
|
||||
const spamHits = require('../model/spamHits')
|
||||
const modLog = require('./modLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('filter')
|
||||
|
||||
const FILTER_MUTE_SECONDS = 600 // 10 minutes
|
||||
|
||||
function botActor(client) {
|
||||
return { id: client.user.id, tag: client.user.tag }
|
||||
}
|
||||
|
||||
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
|
||||
// break the moderation action it accompanies, so failures are swallowed+logged.
|
||||
async function recordFilterHit(message, hitType, matched, actionTaken) {
|
||||
try {
|
||||
await filterHits.record({
|
||||
guildId: message.guildId,
|
||||
hitType,
|
||||
discordUserId: message.author.id,
|
||||
username: message.author.tag,
|
||||
channelId: message.channelId,
|
||||
matched,
|
||||
actionTaken,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('filter hit record failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function recordSpamHit(message, spamType) {
|
||||
try {
|
||||
await spamHits.record({
|
||||
guildId: message.guildId,
|
||||
spamType,
|
||||
discordUserId: message.author.id,
|
||||
username: message.author.tag,
|
||||
channelId: message.channelId,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('spam hit record failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
|
||||
// effect (records this message's timestamp) so it must be evaluated first, and
|
||||
// exactly once — mirroring the original OR-order.
|
||||
function detectSpam(message) {
|
||||
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
|
||||
if (spamFilter.isMassMention(message)) return 'mass_mention'
|
||||
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
|
||||
return null
|
||||
}
|
||||
|
||||
async function isBypassed(message, cache) {
|
||||
if (cache.allowChannels.has(message.channelId)) return true
|
||||
const memberRoles = message.member ? message.member.roles.cache : null
|
||||
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function applyWarnAction(message, reason) {
|
||||
const staff = botActor(message.client)
|
||||
await warnings.add({
|
||||
guildId: message.guildId,
|
||||
targetUserId: message.author.id,
|
||||
targetTag: message.author.tag,
|
||||
staffUserId: staff.id,
|
||||
staffTag: staff.tag,
|
||||
reason,
|
||||
})
|
||||
await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason })
|
||||
}
|
||||
|
||||
async function applyMuteAction(message, reason) {
|
||||
const staff = botActor(message.client)
|
||||
if (message.member && message.member.moderatable) {
|
||||
await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason)
|
||||
}
|
||||
await modLog.record({
|
||||
client: message.client,
|
||||
guildId: message.guildId,
|
||||
actionType: 'mute',
|
||||
target: message.author,
|
||||
staffUser: staff,
|
||||
reason,
|
||||
durationSeconds: FILTER_MUTE_SECONDS,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMessageCreate(message) {
|
||||
if (message.author.bot || !message.guildId) return
|
||||
|
||||
try {
|
||||
const cache = await filterCache.getOrLoad(message.guildId)
|
||||
if (await isBypassed(message, cache)) return
|
||||
|
||||
const foreignCode = await inviteFilter.foreignInviteCode(message)
|
||||
if (foreignCode) {
|
||||
await message.delete().catch(() => {})
|
||||
await recordFilterHit(message, 'invite', foreignCode, 'warn')
|
||||
await applyWarnAction(message, 'Posted a Discord invite link')
|
||||
return
|
||||
}
|
||||
|
||||
const match = findMatch(message.content, cache.words)
|
||||
if (match) {
|
||||
await message.delete().catch(() => {})
|
||||
await recordFilterHit(message, 'word', match.word, match.severity)
|
||||
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
|
||||
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
|
||||
return
|
||||
}
|
||||
|
||||
const spamType = detectSpam(message)
|
||||
if (spamType) {
|
||||
await message.delete().catch(() => {})
|
||||
await recordSpamHit(message, spamType)
|
||||
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('messageFilter failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleMessageCreate }
|
||||
53
bot/src/discord/modLog.js
Normal file
53
bot/src/discord/modLog.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Shared by every moderation command (ban/kick/mute/warn): writes the audit
|
||||
// row and posts the embed to the configured mod-log channel. Takes `client`
|
||||
// as a parameter (from interaction.client) rather than importing
|
||||
// discordManager directly, to avoid a require cycle (discordManager -> commands
|
||||
// -> modLog -> discordManager).
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const db = require('../db')
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('modlog')
|
||||
|
||||
const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 }
|
||||
|
||||
async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) {
|
||||
await db.query(
|
||||
`INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null],
|
||||
)
|
||||
|
||||
try {
|
||||
const channelId = await guildConfig.getModLogChannelId(guildId)
|
||||
if (!channelId) return
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) return
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR[actionType] || 0x9aa5b1)
|
||||
.setTitle(actionType.toUpperCase())
|
||||
.addFields(
|
||||
{ name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true },
|
||||
{ name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true },
|
||||
)
|
||||
.setTimestamp()
|
||||
if (reason) embed.addFields({ name: 'Reason', value: reason })
|
||||
if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true })
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
} catch (err) {
|
||||
log.warn('failed to post mod-log embed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
26
bot/src/discord/newsAnnounce.js
Normal file
26
bot/src/discord/newsAnnounce.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Shared by the /internal/announce webhook (site publishes a news post) and
|
||||
// the manual /announce command (staff re-posts/boosts an existing one) — so
|
||||
// both paths produce an identical embed.
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('news')
|
||||
|
||||
async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) {
|
||||
const channelId = await guildConfig.getNewsChannelId(guildId)
|
||||
if (!channelId) throw new Error('No news channel configured — set one with /news first.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
|
||||
|
||||
const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url)
|
||||
if (excerpt) embed.setDescription(excerpt)
|
||||
if (imageUrl) embed.setImage(imageUrl)
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
log.info('news announced', { title, channelId })
|
||||
}
|
||||
|
||||
module.exports = { postAnnounce }
|
||||
41
bot/src/discord/roleMenuHandler.js
Normal file
41
bot/src/discord/roleMenuHandler.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Button-based self-assignable role menus. customId is `rolemenu:<roleId>` —
|
||||
// the message's own id (not known until after it's sent, so it can't be
|
||||
// embedded in the customId itself) is instead used to look up the tracked
|
||||
// role_menus row and confirm the clicked roleId is really part of that
|
||||
// menu's mapping, so a stale/foreign button can't toggle an untracked role.
|
||||
const roleMenus = require('../model/roleMenus')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('rolemenu')
|
||||
|
||||
const PREFIX = 'rolemenu:'
|
||||
|
||||
// Returns true if this handler owned the interaction (caller should stop
|
||||
// looking for another handler), false if it's not a role-menu button at all.
|
||||
async function handleInteraction(interaction) {
|
||||
if (!interaction.isButton() || !interaction.customId.startsWith(PREFIX)) return false
|
||||
|
||||
const roleId = interaction.customId.slice(PREFIX.length)
|
||||
try {
|
||||
const menu = await roleMenus.getByMessageId(interaction.message.id)
|
||||
if (!menu || !menu.mapping.some((m) => m.roleId === roleId)) {
|
||||
await interaction.reply({ content: 'This role menu is no longer valid.', ephemeral: true })
|
||||
return true
|
||||
}
|
||||
|
||||
const member = interaction.member
|
||||
if (member.roles.cache.has(roleId)) {
|
||||
await member.roles.remove(roleId)
|
||||
await interaction.reply({ content: `Removed <@&${roleId}>.`, ephemeral: true })
|
||||
} else {
|
||||
await member.roles.add(roleId)
|
||||
await interaction.reply({ content: `Added <@&${roleId}>.`, ephemeral: true })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('role menu toggle failed', { message: err.message })
|
||||
await interaction.reply({ content: 'Something went wrong toggling that role.', ephemeral: true }).catch(() => {})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = { handleInteraction }
|
||||
31
bot/src/filter/filterCache.js
Normal file
31
bot/src/filter/filterCache.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// In-memory per-guild filter state (word list + allowlist), loaded at startup
|
||||
// and refreshed on config change — the messageCreate handler runs on every
|
||||
// message, so it must never hit the DB per message (per the spec's
|
||||
// performance note).
|
||||
const filterWords = require('../model/filterWords')
|
||||
const filterAllowlist = require('../model/filterAllowlist')
|
||||
|
||||
const cache = new Map() // guildId -> { words, allowRoles: Set, allowChannels: Set }
|
||||
|
||||
async function load(guildId) {
|
||||
const [words, roles, channels] = await Promise.all([
|
||||
filterWords.list(guildId),
|
||||
filterAllowlist.getRoles(guildId),
|
||||
filterAllowlist.getChannels(guildId),
|
||||
])
|
||||
const entry = { words, allowRoles: new Set(roles), allowChannels: new Set(channels) }
|
||||
cache.set(guildId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
// Lazy-loads on first access per guild (e.g. the first message after boot).
|
||||
async function getOrLoad(guildId) {
|
||||
return cache.get(guildId) || load(guildId)
|
||||
}
|
||||
|
||||
// Called by /filter and /filterallow after any mutation.
|
||||
function refresh(guildId) {
|
||||
return load(guildId)
|
||||
}
|
||||
|
||||
module.exports = { getOrLoad, refresh }
|
||||
26
bot/src/filter/inviteFilter.js
Normal file
26
bot/src/filter/inviteFilter.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Detects Discord invite links and blocks any that don't resolve to the
|
||||
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
|
||||
// (expired/invalid/vanity-only) is treated as foreign too — safer default
|
||||
// than silently letting an unresolvable link through.
|
||||
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
|
||||
|
||||
// Returns the first foreign (or unresolvable) invite code found in the message,
|
||||
// or null if the message contains no foreign invites. Returning the code (rather
|
||||
// than a bare boolean) lets the caller record which invite was blocked.
|
||||
async function foreignInviteCode(message) {
|
||||
const matches = [...message.content.matchAll(INVITE_REGEX)]
|
||||
if (matches.length === 0) return null
|
||||
|
||||
for (const match of matches) {
|
||||
const code = match[1]
|
||||
try {
|
||||
const invite = await message.client.fetchInvite(code)
|
||||
if (invite.guild?.id !== message.guildId) return code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = { foreignInviteCode }
|
||||
33
bot/src/filter/normalize.js
Normal file
33
bot/src/filter/normalize.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Basic obfuscation-resistant normalization for the word filter: lowercase,
|
||||
// common leetspeak substitutions, and collapsing 3+ repeated characters
|
||||
// ("sooooo" -> "so") to one. Deliberately simple per the spec ("start simple,
|
||||
// leave room to tighten later") — spaced-out letters ("b a d") and more exotic
|
||||
// unicode lookalikes aren't handled yet.
|
||||
const SUBS = { 4: 'a', '@': 'a', 3: 'e', 1: 'i', '!': 'i', 0: 'o', $: 's', 5: 's', 7: 't' }
|
||||
const SUB_CHARS = /[4@31!05$7]/g
|
||||
|
||||
function normalize(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(SUB_CHARS, (ch) => SUBS[ch] || ch)
|
||||
.replace(/(.)\1{2,}/g, '$1')
|
||||
}
|
||||
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// Word-boundary match against already-normalized text. `word` is normalized
|
||||
// here too, so callers can pass the raw stored value.
|
||||
function matches(normalizedText, word) {
|
||||
const pattern = new RegExp(`\\b${escapeRegex(normalize(word))}\\b`, 'i')
|
||||
return pattern.test(normalizedText)
|
||||
}
|
||||
|
||||
// Returns the first matching filter_words row ({word, severity}) or null.
|
||||
function findMatch(content, words) {
|
||||
const normalizedText = normalize(content)
|
||||
return words.find((w) => matches(normalizedText, w.word)) || null
|
||||
}
|
||||
|
||||
module.exports = { normalize, matches, findMatch }
|
||||
42
bot/src/filter/spamFilter.js
Normal file
42
bot/src/filter/spamFilter.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Basic in-memory spam/rate-limit detection. Per-user message-rate tracking is
|
||||
// the only stateful piece here (mass-mention/mass-emoji are per-message
|
||||
// counts) — kept in memory rather than the DB since this runs on every
|
||||
// message and needs to be fast.
|
||||
const RATE_LIMIT_COUNT = 5
|
||||
const RATE_LIMIT_WINDOW_MS = 5000
|
||||
const MENTION_THRESHOLD = 5
|
||||
const EMOJI_THRESHOLD = 10
|
||||
const SWEEP_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
const history = new Map() // `${guildId}:${userId}` -> timestamps[]
|
||||
|
||||
function isRateLimited(guildId, userId) {
|
||||
const key = `${guildId}:${userId}`
|
||||
const now = Date.now()
|
||||
const timestamps = (history.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
|
||||
timestamps.push(now)
|
||||
history.set(key, timestamps)
|
||||
return timestamps.length > RATE_LIMIT_COUNT
|
||||
}
|
||||
|
||||
function isMassMention(message) {
|
||||
return message.mentions.users.size + message.mentions.roles.size > MENTION_THRESHOLD
|
||||
}
|
||||
|
||||
const EMOJI_REGEX = /<a?:\w+:\d+>|\p{Extended_Pictographic}/gu
|
||||
|
||||
function isMassEmoji(content) {
|
||||
const count = (content.match(EMOJI_REGEX) || []).length
|
||||
return count > EMOJI_THRESHOLD
|
||||
}
|
||||
|
||||
// Periodic cleanup so `history` doesn't grow unbounded over a long-running
|
||||
// process — drops any key with no recent activity.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, timestamps] of history) {
|
||||
if (timestamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) history.delete(key)
|
||||
}
|
||||
}, SWEEP_INTERVAL_MS).unref()
|
||||
|
||||
module.exports = { isRateLimited, isMassMention, isMassEmoji }
|
||||
50
bot/src/internal/internal.controller.js
Normal file
50
bot/src/internal/internal.controller.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const discordManager = require('../discord/discordManager')
|
||||
const newsAnnounce = require('../discord/newsAnnounce')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('internal')
|
||||
|
||||
// POST /internal/config — called by the main server right after an admin
|
||||
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
|
||||
// (via a GET to the server for the current config, then this same start/stop
|
||||
// logic locally). Body: { token, guildId, enabled }.
|
||||
async function setConfig(req, res) {
|
||||
const { token, guildId, enabled } = req.body || {}
|
||||
try {
|
||||
if (enabled) {
|
||||
if (!token || !guildId) {
|
||||
return res.status(400).json({ message: 'token and guildId are required when enabled' })
|
||||
}
|
||||
await discordManager.start({ token, guildId })
|
||||
} else {
|
||||
await discordManager.stop()
|
||||
}
|
||||
return res.json(discordManager.getStatus())
|
||||
} catch (err) {
|
||||
log.error('setConfig failed', { message: err.message })
|
||||
// Still 200 with an error status — the caller (admin panel) should surface
|
||||
// discordManager's status/statusDetail rather than treat this as a 5xx.
|
||||
return res.json(discordManager.getStatus())
|
||||
}
|
||||
}
|
||||
|
||||
// GET /internal/status — live connection state, polled by the admin panel.
|
||||
function getStatusHandler(req, res) {
|
||||
return res.json(discordManager.getStatus())
|
||||
}
|
||||
|
||||
// POST /internal/announce — called by the main server right after a news
|
||||
// post is published. Body: { title, excerpt, url, imageUrl }.
|
||||
async function announce(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
try {
|
||||
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
|
||||
return res.json({ posted: true })
|
||||
} catch (err) {
|
||||
log.warn('announce failed', { message: err.message })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setConfig, getStatus: getStatusHandler, announce }
|
||||
14
bot/src/internal/internal.routes.js
Normal file
14
bot/src/internal/internal.routes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const express = require('express')
|
||||
|
||||
const requireInternalKey = require('./requireInternalKey')
|
||||
const ctrl = require('./internal.controller')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use(requireInternalKey)
|
||||
|
||||
router.post('/config', ctrl.setConfig)
|
||||
router.get('/status', ctrl.getStatus)
|
||||
router.post('/announce', ctrl.announce)
|
||||
|
||||
module.exports = router
|
||||
19
bot/src/internal/requireInternalKey.js
Normal file
19
bot/src/internal/requireInternalKey.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
|
||||
// server, over the private compose network — never expose this route through
|
||||
// the public reverse proxy. Timing-safe compare so response time can't be used
|
||||
// to brute-force the shared secret one byte at a time.
|
||||
const crypto = require('crypto')
|
||||
|
||||
function requireInternalKey(req, res, next) {
|
||||
const expected = process.env.BOT_INTERNAL_KEY || ''
|
||||
const provided = req.get('X-Internal-Key') || ''
|
||||
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(provided)
|
||||
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
|
||||
if (!match) return res.status(401).json({ message: 'Unauthorized' })
|
||||
return next()
|
||||
}
|
||||
|
||||
module.exports = requireInternalKey
|
||||
37
bot/src/invites/inviteRotator.js
Normal file
37
bot/src/invites/inviteRotator.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Shared by both /invite rotate and the weekly cron job (inviteScheduler.js)
|
||||
// so manual and automatic rotations log identically. maxAge is set to match
|
||||
// the rotation cadence as defense-in-depth: if the scheduled rotation were
|
||||
// ever to silently stop running, the invite still expires on its own instead
|
||||
// of staying live forever.
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const inviteLog = require('../model/inviteLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
const ROTATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 // 7 days
|
||||
|
||||
async function rotate(client, guildId, { triggeredBy, triggeredByTag } = {}) {
|
||||
const channelId = await guildConfig.getInviteChannelId(guildId)
|
||||
if (!channelId) throw new Error('No invite channel configured — set one with /invite channel first.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) throw new Error('Configured invite channel is missing or not text-based.')
|
||||
|
||||
const current = await inviteLog.getCurrent(guildId)
|
||||
if (current) {
|
||||
try {
|
||||
await channel.guild.invites.delete(current.invite_code, 'Invite rotation')
|
||||
} catch (err) {
|
||||
log.warn('failed to revoke previous invite (may already be gone)', { message: err.message })
|
||||
}
|
||||
await inviteLog.markRevoked(current.id)
|
||||
}
|
||||
|
||||
const invite = await channel.createInvite({ maxAge: ROTATION_MAX_AGE_SECONDS, unique: true, reason: 'Invite rotation' })
|
||||
await inviteLog.record({ guildId, channelId, inviteCode: invite.code, triggeredBy, triggeredByTag })
|
||||
log.info('invite rotated', { code: invite.code, triggeredBy: triggeredByTag || 'automatic (scheduled)' })
|
||||
return invite
|
||||
}
|
||||
|
||||
module.exports = { rotate }
|
||||
31
bot/src/invites/inviteScheduler.js
Normal file
31
bot/src/invites/inviteScheduler.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Weekly automatic invite rotation (Sundays at midnight). A missing invite
|
||||
// channel config just skips quietly (warn-logged) — most guilds won't set
|
||||
// this up on day one, and that shouldn't spam errors every week until they do.
|
||||
const cron = require('node-cron')
|
||||
|
||||
const inviteRotator = require('./inviteRotator')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
let task = null
|
||||
|
||||
function start(client, guildId) {
|
||||
task = cron.schedule('0 0 * * 0', async () => {
|
||||
try {
|
||||
await inviteRotator.rotate(client, guildId, {})
|
||||
} catch (err) {
|
||||
log.warn('scheduled invite rotation skipped', { message: err.message })
|
||||
}
|
||||
})
|
||||
log.info('invite rotation scheduler started')
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (task) {
|
||||
task.stop()
|
||||
task = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop }
|
||||
40
bot/src/model/filterAllowlist.js
Normal file
40
bot/src/model/filterAllowlist.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Roles/channels that bypass word/invite/spam filtering entirely (staff roles,
|
||||
// bot-commands channels, etc.). Stored as CSV in guild_config rather than a
|
||||
// separate table — short, rarely-changed lists.
|
||||
const guildConfig = require('./guildConfig')
|
||||
|
||||
const ROLES_KEY = 'filter_allow_roles'
|
||||
const CHANNELS_KEY = 'filter_allow_channels'
|
||||
|
||||
function parseCsv(value) {
|
||||
return value ? value.split(',').filter(Boolean) : []
|
||||
}
|
||||
|
||||
async function getRoles(guildId) {
|
||||
return parseCsv(await guildConfig.get(guildId, ROLES_KEY))
|
||||
}
|
||||
|
||||
async function getChannels(guildId) {
|
||||
return parseCsv(await guildConfig.get(guildId, CHANNELS_KEY))
|
||||
}
|
||||
|
||||
// Toggle: adds the id if absent, removes it if present. Returns the new state (true = now allowed).
|
||||
async function toggleRole(guildId, roleId) {
|
||||
const roles = await getRoles(guildId)
|
||||
const idx = roles.indexOf(roleId)
|
||||
if (idx === -1) roles.push(roleId)
|
||||
else roles.splice(idx, 1)
|
||||
await guildConfig.set(guildId, ROLES_KEY, roles.join(','))
|
||||
return idx === -1
|
||||
}
|
||||
|
||||
async function toggleChannel(guildId, channelId) {
|
||||
const channels = await getChannels(guildId)
|
||||
const idx = channels.indexOf(channelId)
|
||||
if (idx === -1) channels.push(channelId)
|
||||
else channels.splice(idx, 1)
|
||||
await guildConfig.set(guildId, CHANNELS_KEY, channels.join(','))
|
||||
return idx === -1
|
||||
}
|
||||
|
||||
module.exports = { getRoles, getChannels, toggleRole, toggleChannel }
|
||||
15
bot/src/model/filterHits.js
Normal file
15
bot/src/model/filterHits.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
|
||||
// word filter or foreign-invite filter deletes a message. mod_actions still
|
||||
// records the resulting warn/mute separately. Schema: server/db/schema.sql
|
||||
// (filter_hits).
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
|
||||
await db.query(
|
||||
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
22
bot/src/model/filterWords.js
Normal file
22
bot/src/model/filterWords.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function add({ guildId, word, severity, addedBy, addedByTag }) {
|
||||
await db.query(
|
||||
`INSERT INTO filter_words (guild_id, word, severity, added_by, added_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE severity = VALUES(severity), added_by = VALUES(added_by), added_by_tag = VALUES(added_by_tag)`,
|
||||
[guildId, word.toLowerCase(), severity || 'delete', addedBy || null, addedByTag || null],
|
||||
)
|
||||
}
|
||||
|
||||
// Returns true if a row was actually removed.
|
||||
async function remove(guildId, word) {
|
||||
const res = await db.query('DELETE FROM filter_words WHERE guild_id = ? AND word = ?', [guildId, word.toLowerCase()])
|
||||
return Number(res.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
async function list(guildId) {
|
||||
return db.query('SELECT word, severity FROM filter_words WHERE guild_id = ? ORDER BY word ASC', [guildId])
|
||||
}
|
||||
|
||||
module.exports = { add, remove, list }
|
||||
47
bot/src/model/guildConfig.js
Normal file
47
bot/src/model/guildConfig.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Per-guild key/value config the bot owns (see guild_config in
|
||||
// server/db/schema.sql). Generic get/set now; filters/schedules/role-menu
|
||||
// config reuses this same table in later phases.
|
||||
const db = require('../db')
|
||||
|
||||
const MOD_LOG_CHANNEL_KEY = 'mod_log_channel_id'
|
||||
const AUTO_ROLE_KEY = 'auto_role_id'
|
||||
const INVITE_CHANNEL_KEY = 'invite_channel_id'
|
||||
const NEWS_CHANNEL_KEY = 'news_channel_id'
|
||||
|
||||
async function get(guildId, key) {
|
||||
const rows = await db.query('SELECT value FROM guild_config WHERE guild_id = ? AND `key` = ? LIMIT 1', [guildId, key])
|
||||
return rows[0] ? rows[0].value : null
|
||||
}
|
||||
|
||||
async function set(guildId, key, value) {
|
||||
await db.query(
|
||||
`INSERT INTO guild_config (guild_id, \`key\`, value) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
||||
[guildId, key, value],
|
||||
)
|
||||
}
|
||||
|
||||
const getModLogChannelId = (guildId) => get(guildId, MOD_LOG_CHANNEL_KEY)
|
||||
const setModLogChannelId = (guildId, channelId) => set(guildId, MOD_LOG_CHANNEL_KEY, channelId)
|
||||
|
||||
const getAutoRoleId = (guildId) => get(guildId, AUTO_ROLE_KEY)
|
||||
const setAutoRoleId = (guildId, roleId) => set(guildId, AUTO_ROLE_KEY, roleId)
|
||||
|
||||
const getInviteChannelId = (guildId) => get(guildId, INVITE_CHANNEL_KEY)
|
||||
const setInviteChannelId = (guildId, channelId) => set(guildId, INVITE_CHANNEL_KEY, channelId)
|
||||
|
||||
const getNewsChannelId = (guildId) => get(guildId, NEWS_CHANNEL_KEY)
|
||||
const setNewsChannelId = (guildId, channelId) => set(guildId, NEWS_CHANNEL_KEY, channelId)
|
||||
|
||||
module.exports = {
|
||||
get,
|
||||
set,
|
||||
getModLogChannelId,
|
||||
setModLogChannelId,
|
||||
getAutoRoleId,
|
||||
setAutoRoleId,
|
||||
getInviteChannelId,
|
||||
setInviteChannelId,
|
||||
getNewsChannelId,
|
||||
setNewsChannelId,
|
||||
}
|
||||
29
bot/src/model/inviteLog.js
Normal file
29
bot/src/model/inviteLog.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, channelId, inviteCode, triggeredBy, triggeredByTag }) {
|
||||
const res = await db.query(
|
||||
`INSERT INTO invite_log (guild_id, channel_id, invite_code, triggered_by, triggered_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, inviteCode, triggeredBy || null, triggeredByTag || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// The active (not-yet-revoked) invite for a guild, if any.
|
||||
async function getCurrent(guildId) {
|
||||
const rows = await db.query(
|
||||
'SELECT * FROM invite_log WHERE guild_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
|
||||
[guildId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function markRevoked(id) {
|
||||
await db.query('UPDATE invite_log SET revoked_at = NOW() WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
async function list(guildId, limit = 10) {
|
||||
return db.query('SELECT * FROM invite_log WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', [guildId, limit])
|
||||
}
|
||||
|
||||
module.exports = { record, getCurrent, markRevoked, list }
|
||||
14
bot/src/model/memberEvents.js
Normal file
14
bot/src/model/memberEvents.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
|
||||
// the moderation dashboard's members feed + invite-usage view. Schema in
|
||||
// server/db/schema.sql (member_events).
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
|
||||
await db.query(
|
||||
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
17
bot/src/model/roleMenus.js
Normal file
17
bot/src/model/roleMenus.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function add({ guildId, channelId, messageId, mapping, createdBy }) {
|
||||
await db.query(
|
||||
`INSERT INTO role_menus (guild_id, channel_id, message_id, mapping, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, messageId, JSON.stringify(mapping), createdBy || null],
|
||||
)
|
||||
}
|
||||
|
||||
async function getByMessageId(messageId) {
|
||||
const rows = await db.query('SELECT * FROM role_menus WHERE message_id = ? LIMIT 1', [messageId])
|
||||
if (!rows[0]) return null
|
||||
return { ...rows[0], mapping: JSON.parse(rows[0].mapping) }
|
||||
}
|
||||
|
||||
module.exports = { add, getByMessageId }
|
||||
57
bot/src/model/scheduledMessages.js
Normal file
57
bot/src/model/scheduledMessages.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const db = require('../db')
|
||||
|
||||
async function addRecurring({ guildId, channelId, content, cronExpression, createdBy, createdByTag }) {
|
||||
const res = await db.query(
|
||||
`INSERT INTO scheduled_messages (guild_id, channel_id, content, cron_expression, created_by, created_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, content, cronExpression, createdBy || null, createdByTag || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function addOnce({ guildId, channelId, content, runAt, createdBy, createdByTag }) {
|
||||
const res = await db.query(
|
||||
`INSERT INTO scheduled_messages (guild_id, channel_id, content, run_at, created_by, created_by_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, channelId, content, runAt, createdBy || null, createdByTag || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Returns true if a row was actually removed (scoped to the guild so one
|
||||
// guild can't remove another's rows).
|
||||
async function remove(guildId, id) {
|
||||
const res = await db.query('DELETE FROM scheduled_messages WHERE id = ? AND guild_id = ?', [id, guildId])
|
||||
return Number(res.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
async function list(guildId) {
|
||||
return db.query(
|
||||
`SELECT id, channel_id, content, cron_expression, run_at, enabled, sent_at FROM scheduled_messages
|
||||
WHERE guild_id = ? ORDER BY id ASC`,
|
||||
[guildId],
|
||||
)
|
||||
}
|
||||
|
||||
// All enabled recurring rows across every guild the bot serves — v1 only
|
||||
// ever has one, but the scheduler doesn't need to special-case that.
|
||||
async function listEnabledRecurring() {
|
||||
return db.query(
|
||||
`SELECT id, guild_id, channel_id, content, cron_expression FROM scheduled_messages
|
||||
WHERE cron_expression IS NOT NULL AND enabled = 1`,
|
||||
)
|
||||
}
|
||||
|
||||
// One-off rows due to post right now.
|
||||
async function listDueOneOff() {
|
||||
return db.query(
|
||||
`SELECT id, guild_id, channel_id, content FROM scheduled_messages
|
||||
WHERE run_at IS NOT NULL AND sent_at IS NULL AND enabled = 1 AND run_at <= NOW()`,
|
||||
)
|
||||
}
|
||||
|
||||
async function markSent(id) {
|
||||
await db.query('UPDATE scheduled_messages SET sent_at = NOW() WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
module.exports = { addRecurring, addOnce, remove, list, listEnabledRecurring, listDueOneOff, markSent }
|
||||
14
bot/src/model/spamHits.js
Normal file
14
bot/src/model/spamHits.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
|
||||
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
|
||||
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
|
||||
const db = require('../db')
|
||||
|
||||
async function record({ guildId, spamType, discordUserId, username, channelId }) {
|
||||
await db.query(
|
||||
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[guildId, spamType, discordUserId, username || null, channelId || null],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
26
bot/src/model/tempRoles.js
Normal file
26
bot/src/model/tempRoles.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const db = require('../db')
|
||||
|
||||
// Upsert — re-granting the same temp role refreshes its expiry instead of
|
||||
// creating a duplicate row (see UNIQUE(guild,user,role) in schema.sql).
|
||||
async function add({ guildId, userId, roleId, expiresAt, createdBy }) {
|
||||
await db.query(
|
||||
`INSERT INTO temp_roles (guild_id, user_id, role_id, expires_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), created_by = VALUES(created_by)`,
|
||||
[guildId, userId, roleId, expiresAt, createdBy || null],
|
||||
)
|
||||
}
|
||||
|
||||
async function remove(guildId, userId, roleId) {
|
||||
await db.query('DELETE FROM temp_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?', [guildId, userId, roleId])
|
||||
}
|
||||
|
||||
async function listExpired() {
|
||||
return db.query('SELECT id, guild_id, user_id, role_id FROM temp_roles WHERE expires_at <= NOW()')
|
||||
}
|
||||
|
||||
async function removeById(id) {
|
||||
await db.query('DELETE FROM temp_roles WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
module.exports = { add, remove, listExpired, removeById }
|
||||
26
bot/src/model/warnings.js
Normal file
26
bot/src/model/warnings.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Standing warnings (separate from mod_actions so /warnings can list a
|
||||
// user's active warnings). expires_at is always NULL for now — decay/escalation
|
||||
// (e.g. "3 active warns -> auto-mute") is deferred past Phase 2, see
|
||||
// warn.command.js.
|
||||
const db = require('../db')
|
||||
|
||||
async function add({ guildId, targetUserId, targetTag, staffUserId, staffTag, reason }) {
|
||||
await db.query(
|
||||
`INSERT INTO warnings (guild_id, target_user_id, target_tag, staff_user_id, staff_tag, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, targetUserId, targetTag || null, staffUserId, staffTag || null, reason || null],
|
||||
)
|
||||
}
|
||||
|
||||
// Active = not expired. Every row is active today since expires_at is never
|
||||
// set, but the query is written to already respect it once decay lands.
|
||||
async function listActive(guildId, targetUserId) {
|
||||
return db.query(
|
||||
`SELECT id, reason, staff_tag, created_at FROM warnings
|
||||
WHERE guild_id = ? AND target_user_id = ? AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC`,
|
||||
[guildId, targetUserId],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { add, listActive }
|
||||
48
bot/src/roles/tempRoleSweeper.js
Normal file
48
bot/src/roles/tempRoleSweeper.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Once-a-minute sweep for expired temp_roles: removes the Discord role (best
|
||||
// effort — the member/guild/role may already be gone) then deletes the row
|
||||
// regardless, so a stale row can never block future re-grants of the same
|
||||
// role to the same member.
|
||||
const cron = require('node-cron')
|
||||
|
||||
const tempRoles = require('../model/tempRoles')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('temproles')
|
||||
|
||||
let client = null
|
||||
let task = null
|
||||
|
||||
async function sweep() {
|
||||
try {
|
||||
const expired = await tempRoles.listExpired()
|
||||
for (const row of expired) {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(row.guild_id)
|
||||
const member = await guild.members.fetch(row.user_id).catch(() => null)
|
||||
if (member) await member.roles.remove(row.role_id).catch(() => {})
|
||||
} catch (err) {
|
||||
log.warn('failed to remove expired temp role', { message: err.message, roleId: row.role_id, userId: row.user_id })
|
||||
} finally {
|
||||
await tempRoles.removeById(row.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('temp role sweep failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function start(discordClient) {
|
||||
client = discordClient
|
||||
task = cron.schedule('* * * * *', sweep)
|
||||
log.info('temp role sweeper started')
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (task) {
|
||||
task.stop()
|
||||
task = null
|
||||
}
|
||||
client = null
|
||||
}
|
||||
|
||||
module.exports = { start, stop }
|
||||
83
bot/src/scheduler/scheduler.js
Normal file
83
bot/src/scheduler/scheduler.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// Recurring + one-off scheduled channel messages. Recurring rows are each
|
||||
// registered as their own node-cron task; one-off rows are picked up by a
|
||||
// once-a-minute sweep that checks for anything due and marks it sent so it
|
||||
// never reposts. Needs a live discord.js Client to actually send — wired up
|
||||
// by discordManager.js (start() once the client is ready, stop() alongside
|
||||
// client teardown).
|
||||
const cron = require('node-cron')
|
||||
|
||||
const scheduledMessages = require('../model/scheduledMessages')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('scheduler')
|
||||
|
||||
let discordClient = null
|
||||
const recurringTasks = new Map() // id -> node-cron ScheduledTask
|
||||
let sweepTask = null
|
||||
|
||||
async function sendToChannel(channelId, content) {
|
||||
try {
|
||||
const channel = await discordClient.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) {
|
||||
log.warn('scheduled message skipped — channel missing or not text-based', { channelId })
|
||||
return
|
||||
}
|
||||
await channel.send({ content })
|
||||
log.info('sent scheduled message', { channelId })
|
||||
} catch (err) {
|
||||
log.warn('failed to send scheduled message', { channelId, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecurring() {
|
||||
for (const task of recurringTasks.values()) task.stop()
|
||||
recurringTasks.clear()
|
||||
|
||||
const rows = await scheduledMessages.listEnabledRecurring()
|
||||
for (const row of rows) {
|
||||
if (!cron.validate(row.cron_expression)) {
|
||||
log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression })
|
||||
continue
|
||||
}
|
||||
const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content))
|
||||
recurringTasks.set(row.id, task)
|
||||
}
|
||||
log.info('loaded recurring scheduled messages', { count: recurringTasks.size })
|
||||
}
|
||||
|
||||
async function sweepDueOneOff() {
|
||||
try {
|
||||
const due = await scheduledMessages.listDueOneOff()
|
||||
for (const row of due) {
|
||||
await sendToChannel(row.channel_id, row.content)
|
||||
await scheduledMessages.markSent(row.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('one-off sweep failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function start(client) {
|
||||
discordClient = client
|
||||
await loadRecurring()
|
||||
sweepTask = cron.schedule('* * * * *', sweepDueOneOff)
|
||||
log.info('scheduler started')
|
||||
}
|
||||
|
||||
// Called by /schedule after any add/remove so changes apply without a restart.
|
||||
async function refresh() {
|
||||
if (!discordClient) return
|
||||
await loadRecurring()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
for (const task of recurringTasks.values()) task.stop()
|
||||
recurringTasks.clear()
|
||||
if (sweepTask) {
|
||||
sweepTask.stop()
|
||||
sweepTask = null
|
||||
}
|
||||
discordClient = null
|
||||
}
|
||||
|
||||
module.exports = { start, stop, refresh }
|
||||
52
bot/src/server.js
Normal file
52
bot/src/server.js
Normal file
@@ -0,0 +1,52 @@
|
||||
require('dotenv').config()
|
||||
|
||||
const app = require('./app')
|
||||
const bootstrap = require('./bootstrap')
|
||||
const createLogger = require('./utils/logger')
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const log = createLogger('server')
|
||||
const PORT = Number(process.env.PORT) || 4100
|
||||
const HOST = '0.0.0.0'
|
||||
|
||||
async function start() {
|
||||
log.info(`starting UOMysticmoon bot v${pkg.version}`, {
|
||||
node: process.version,
|
||||
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||
})
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
await bootstrap()
|
||||
|
||||
setupShutdown(server)
|
||||
}
|
||||
|
||||
function setupShutdown(server) {
|
||||
let closing = false
|
||||
const shutdown = async (signal) => {
|
||||
if (closing) return
|
||||
closing = true
|
||||
log.warn(`${signal} received — shutting down gracefully`)
|
||||
server.close(() => log.info('internal API closed'))
|
||||
await discordManager.stop()
|
||||
await createLogger.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('uncaughtException', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
log.error('failed to start bot', err)
|
||||
process.exit(1)
|
||||
})
|
||||
42
bot/src/site/siteApiClient.js
Normal file
42
bot/src/site/siteApiClient.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Read-only client for the main site's PUBLIC API (no shared secret — this is
|
||||
// the same unauthenticated data any visitor's browser can fetch). Used by
|
||||
// /wiki (search) and /announce (re-post an existing news item). Distinct from
|
||||
// botInternalClient.js, which is the shared-secret-gated server<->bot channel.
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('site-api')
|
||||
|
||||
const BASE_URL = (process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public').replace(/\/+$/, '')
|
||||
const TIMEOUT_MS = 5000
|
||||
|
||||
async function call(path) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, { signal: controller.signal })
|
||||
const data = await res.json().catch(() => null)
|
||||
// Public content routes 503 with this shape while the site is in
|
||||
// maintenance mode (see server/src/middleware/siteMode.js) — surface it
|
||||
// distinctly so commands can show a clear message instead of a generic error.
|
||||
if (res.status === 503 && data?.mode === 'maintenance') {
|
||||
return { ok: false, maintenance: true, message: data.message }
|
||||
}
|
||||
if (!res.ok) return { ok: false, error: `site responded ${res.status}` }
|
||||
return { ok: true, data }
|
||||
} catch (err) {
|
||||
log.warn('site API call failed', { path, message: err.message })
|
||||
return { ok: false, error: err.message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function getNewsPost(idOrSlug) {
|
||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||
}
|
||||
|
||||
function searchWiki(query) {
|
||||
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
module.exports = { getNewsPost, searchWiki }
|
||||
16
bot/src/utils/duration.js
Normal file
16
bot/src/utils/duration.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds.
|
||||
// Returns null for anything unparseable. Discord's own timeout API caps at 28
|
||||
// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input.
|
||||
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
||||
|
||||
const MAX_TIMEOUT_MS = 28 * 86_400_000
|
||||
|
||||
function parseDuration(input) {
|
||||
if (!input) return null
|
||||
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim())
|
||||
if (!match) return null
|
||||
const [, amount, unit] = match
|
||||
return Number(amount) * UNIT_MS[unit.toLowerCase()]
|
||||
}
|
||||
|
||||
module.exports = { parseDuration, MAX_TIMEOUT_MS }
|
||||
97
bot/src/utils/logger.js
Normal file
97
bot/src/utils/logger.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Dual-transport logger: writes to the console AND to a log file.
|
||||
// Levels: error | warn | info | debug.
|
||||
// LOG_LEVEL console verbosity (default info)
|
||||
// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk)
|
||||
// LOG_TO_FILE enable file logging (default true)
|
||||
// LOG_DIR log directory (default <bot>/logs)
|
||||
// LOG_FILE log file name (default bot.log)
|
||||
//
|
||||
// Copied from server/src/utils/logger.js rather than shared — the bot is an
|
||||
// independently deployable process with its own package.json/Dockerfile.
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
|
||||
|
||||
const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
|
||||
const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug
|
||||
|
||||
// Color only on an interactive TTY — never in files or Docker logs.
|
||||
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null
|
||||
const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' }
|
||||
const RESET = '\x1b[0m'
|
||||
|
||||
// ── File transport ────────────────────────────────────────────────────
|
||||
const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false'
|
||||
let fileStream = null
|
||||
let logFilePath = null
|
||||
|
||||
if (fileEnabled) {
|
||||
try {
|
||||
const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
logFilePath = path.join(dir, process.env.LOG_FILE || 'bot.log')
|
||||
fileStream = fs.createWriteStream(logFilePath, { flags: 'a' })
|
||||
fileStream.on('error', (err) => {
|
||||
process.stderr.write(`[logger] file logging disabled: ${err.message}\n`)
|
||||
fileStream = null
|
||||
})
|
||||
} catch (err) {
|
||||
process.stderr.write(`[logger] could not open log file: ${err.message}\n`)
|
||||
fileStream = null
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(meta) {
|
||||
if (meta == null) return ''
|
||||
if (typeof meta === 'string') return meta
|
||||
if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack })
|
||||
try {
|
||||
return JSON.stringify(meta)
|
||||
} catch {
|
||||
return String(meta)
|
||||
}
|
||||
}
|
||||
|
||||
function emit(level, tag, msg, meta) {
|
||||
const levelNum = LEVELS[level]
|
||||
if (levelNum === undefined) return
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const lvl = level.toUpperCase().padEnd(5)
|
||||
const label = tag ? ` [${tag}]` : ''
|
||||
const metaStr = meta === undefined ? '' : ` ${fmt(meta)}`
|
||||
const plain = `${ts} ${lvl}${label} ${msg}${metaStr}`
|
||||
|
||||
// Console transport
|
||||
if (levelNum <= consoleThreshold) {
|
||||
const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain
|
||||
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout
|
||||
stream.write(`${line}\n`)
|
||||
}
|
||||
|
||||
// File transport (plain text, no color)
|
||||
if (fileStream && levelNum <= fileThreshold) {
|
||||
fileStream.write(`${plain}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
function createLogger(tag) {
|
||||
return {
|
||||
error: (msg, meta) => emit('error', tag, msg, meta),
|
||||
warn: (msg, meta) => emit('warn', tag, msg, meta),
|
||||
info: (msg, meta) => emit('info', tag, msg, meta),
|
||||
debug: (msg, meta) => emit('debug', tag, msg, meta),
|
||||
}
|
||||
}
|
||||
|
||||
// Flush and close the file stream (called on graceful shutdown).
|
||||
createLogger.close = () =>
|
||||
new Promise((resolve) => {
|
||||
if (fileStream) fileStream.end(resolve)
|
||||
else resolve()
|
||||
})
|
||||
|
||||
createLogger.emit = emit
|
||||
createLogger.logFilePath = logFilePath
|
||||
module.exports = createLogger
|
||||
@@ -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'
|
||||
@@ -26,7 +27,13 @@ import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||
import 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 (
|
||||
@@ -69,8 +76,23 @@ 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 />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -41,8 +41,17 @@ function safeParse(text) {
|
||||
export const api = {
|
||||
// ----- auth -----
|
||||
me: () => req('/auth/me'),
|
||||
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
|
||||
// `extra` carries the honeypot field (and any future login fields).
|
||||
login: (username, password, extra = {}) =>
|
||||
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||
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'),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
@@ -104,10 +113,78 @@ export const api = {
|
||||
getSettings: () => req('/admin/settings'),
|
||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
listUsers: () => req('/admin/users'),
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||
modMembers: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modFilterHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modSpamHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||
modUserActions: (discordId, params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||
addModNote: (discordId, data) =>
|
||||
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
||||
|
||||
// ----- account security (self-service 2FA) -----
|
||||
getAccount: () => req('/admin/account'),
|
||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
|
||||
// ----- linked SSO identities (self-service) -----
|
||||
linkedIdentities: () => req('/admin/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
||||
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- Discord bot control (admin only) -----
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -107,12 +107,15 @@ function content(element) {
|
||||
case 'buttons':
|
||||
return <Buttons props={element.props || {}} />
|
||||
case 'moon': {
|
||||
const size = element.props?.size || 96
|
||||
const glow = element.props?.glow ?? 0.45
|
||||
const props = element.props || {}
|
||||
const size = props.size || 96
|
||||
const glow = props.glow ?? 0.45
|
||||
// Image source is configurable; old layouts with no src fall back to the
|
||||
// default hero moon so they render exactly as before. Size/glow unchanged.
|
||||
return (
|
||||
<img
|
||||
src={MOON_IMAGE}
|
||||
alt=""
|
||||
src={props.src || MOON_IMAGE}
|
||||
alt={props.alt || ''}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: size,
|
||||
|
||||
37
client/src/components/ProviderIcon.jsx
Normal file
37
client/src/components/ProviderIcon.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Inline SVG brand icons for SSO providers. No binary assets — these scale
|
||||
// crisply at any size and keep their own brand colors. `icon` matches the
|
||||
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
|
||||
// Anything unknown falls back to a neutral key glyph in the current text color.
|
||||
|
||||
function GoogleMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscordMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="#5865F2" aria-hidden="true" focusable="false">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProviderIcon({ icon, size = 18 }) {
|
||||
if (icon === 'google') return <GoogleMark size={size} />
|
||||
if (icon === 'discord') return <DiscordMark size={size} />
|
||||
return <GenericMark size={size} />
|
||||
}
|
||||
11
client/src/components/RoleGate.jsx
Normal file
11
client/src/components/RoleGate.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
|
||||
// Client-side role gate for admin sub-sections. Real enforcement is server-side
|
||||
// (requireRole); this just keeps the UI honest — a user without one of `roles`
|
||||
// is redirected rather than shown a page that will only 403 on every call.
|
||||
export default function RoleGate({ roles, children, redirect = '/admin' }) {
|
||||
const { user } = useAuth()
|
||||
if (user && !roles.includes(user.role)) return <Navigate to={redirect} replace />
|
||||
return children
|
||||
}
|
||||
@@ -22,12 +22,29 @@ export function AuthProvider({ children }) {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const login = useCallback(async (username, password) => {
|
||||
const data = await api.login(username, password)
|
||||
// Step 1. Returns { user } on success, or { totpRequired, challenge } when the
|
||||
// account has 2FA on (caller then calls loginTotp). `extra` carries honeypot.
|
||||
const login = useCallback(async (username, password, extra) => {
|
||||
const data = await api.login(username, password, extra)
|
||||
if (data.user) setUser(data.user)
|
||||
return data
|
||||
}, [])
|
||||
|
||||
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
||||
const loginTotp = useCallback(async (challenge, code) => {
|
||||
const data = await api.loginTotp(challenge, code)
|
||||
setUser(data.user)
|
||||
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()
|
||||
@@ -37,7 +54,7 @@ export function AuthProvider({ children }) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,22 @@ 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' },
|
||||
]
|
||||
|
||||
const TITLES = {
|
||||
@@ -19,9 +27,14 @@ 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',
|
||||
}
|
||||
|
||||
const navBtnBase = {
|
||||
@@ -40,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')
|
||||
@@ -86,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}
|
||||
|
||||
@@ -1,33 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||
const SSO_ERRORS = {
|
||||
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
||||
denied: 'Sign-in was cancelled.',
|
||||
unavailable: 'That sign-in method is not available right now.',
|
||||
bad_state: 'Your sign-in session expired. Please try again.',
|
||||
error: 'Could not complete sign-in. Please try again.',
|
||||
}
|
||||
|
||||
const BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
|
||||
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
||||
// skip) so real users never fill it but naive scripted bots do. Name must match
|
||||
// the server's HONEYPOT_FIELD ('company').
|
||||
const honeypotStyle = {
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: 'auto',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { user, login } = useAuth()
|
||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dest = location.state?.from?.pathname || '/admin'
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
|
||||
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
|
||||
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [ssoTotp, setSsoTotp] = useState(false)
|
||||
|
||||
// SSO providers to offer (empty if none configured) + any error the callback
|
||||
// bounced us back with (?sso_error=...).
|
||||
const [providers, setProviders] = useState([])
|
||||
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||
|
||||
// Already signed in → go straight to the panel.
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
let active = true
|
||||
api
|
||||
.authProviders()
|
||||
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||
.catch(() => active && setProviders([]))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Full-page redirect into the provider's OAuth flow, preserving the intended
|
||||
// destination so the callback can return the user there.
|
||||
function startSso(provider) {
|
||||
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
|
||||
window.location.assign(provider.loginUrl + q)
|
||||
}
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
const data = await login(username, password, { company })
|
||||
if (data.totpRequired) {
|
||||
setChallenge(data.challenge)
|
||||
setStage('totp')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||
@@ -35,6 +108,33 @@ export default function AdminLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitTotp(e) {
|
||||
e.preventDefault()
|
||||
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(
|
||||
expired
|
||||
? 'Your verification session expired. Please sign in again.'
|
||||
: 'Invalid verification code.',
|
||||
)
|
||||
setBusy(false)
|
||||
if (expired) {
|
||||
setStage('creds')
|
||||
setSsoTotp(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
@@ -63,7 +163,7 @@ export default function AdminLogin() {
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 12,
|
||||
@@ -73,6 +173,8 @@ export default function AdminLogin() {
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{stage === 'creds' ? (
|
||||
<>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Username</span>
|
||||
<input
|
||||
@@ -95,9 +197,43 @@ export default function AdminLogin() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
{error}
|
||||
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
|
||||
<div style={honeypotStyle} aria-hidden="true">
|
||||
<label>
|
||||
Company
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Authentication code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Enter the code from your authenticator app.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{(error || (stage === 'creds' && ssoError)) && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
{error || ssoError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -107,8 +243,47 @@ export default function AdminLogin() {
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
||||
{stage === 'creds' && providers.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => startSso(p)}
|
||||
className="btn"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
padding: 11,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Continue with {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Protected area — not indexed. Sessions expire after 1 day.
|
||||
</p>
|
||||
|
||||
308
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
308
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
// the provider's OAuth flow (/auth/sso/:id/link) and returns here with ?linked
|
||||
// or ?link_error. Only providers that are enabled + valid can be linked.
|
||||
function LinkedAccounts() {
|
||||
const [linked, setLinked] = useState(null)
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.admin.linkedIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
setAvailable(Array.isArray(avail) ? avail : [])
|
||||
} catch {
|
||||
setError('Could not load linked accounts.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.admin.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Linked accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
to an account it is linked to — linking here is what grants that access.
|
||||
</p>
|
||||
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>
|
||||
{banner.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linkable.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||
{linkable.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link`)}
|
||||
className="btn"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Link {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linked.length === 0 && linkable.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem' }}>
|
||||
No SSO providers are enabled. Configure them under <strong>Authentication</strong>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Self-service account security: enable / disable optional TOTP two-factor.
|
||||
export default function AccountAdmin() {
|
||||
const [account, setAccount] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Enrollment state.
|
||||
const [setup, setSetup] = useState(null) // { qr, otpauthUrl }
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setAccount(await api.admin.getAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
async function beginSetup() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
setSetup(await api.admin.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmEnable() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpEnable(code.trim())
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
setMsg('Two-factor authentication is now enabled.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not enable two-factor.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpDisable(code.trim())
|
||||
setCode('')
|
||||
setMsg('Two-factor authentication has been disabled.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not disable two-factor.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = account?.totp_enabled
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 560 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Two-factor authentication
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Add a time-based one-time code (TOTP) from an authenticator app as a second step at login.
|
||||
Optional, and only affects your own account.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
fontSize: '0.82rem',
|
||||
color: enabled ? '#7fd0a4' : 'var(--muted)',
|
||||
marginBottom: 22,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: '50%',
|
||||
background: enabled ? '#7fd0a4' : 'var(--dim)',
|
||||
}}
|
||||
/>
|
||||
{enabled ? 'Enabled' : 'Not enabled'}
|
||||
</div>
|
||||
|
||||
{/* Enable flow */}
|
||||
{!enabled && !setup && (
|
||||
<div>
|
||||
<button onClick={beginSetup} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Preparing…' : 'Set up two-factor'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && setup && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||
1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.
|
||||
</p>
|
||||
<img
|
||||
src={setup.qr}
|
||||
alt="TOTP QR code"
|
||||
width={180}
|
||||
height={180}
|
||||
style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }}
|
||||
/>
|
||||
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||
<span className="field-label">Verification code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={confirmEnable} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Enabling…' : 'Confirm & enable'}
|
||||
</button>
|
||||
<button onClick={() => setSetup(null)} disabled={busy} className="pill">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable flow */}
|
||||
{enabled && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||
Enter a current code from your authenticator to turn two-factor off.
|
||||
</p>
|
||||
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||
<span className="field-label">Verification code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||
{busy ? 'Disabling…' : 'Disable two-factor'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
|
||||
<LinkedAccounts />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
380
client/src/routes/admin/views/AuthProvidersAdmin.jsx
Normal file
380
client/src/routes/admin/views/AuthProvidersAdmin.jsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin config for authentication providers. Local password + TOTP is always on
|
||||
// (informational tab). Google/Discord are built-ins with a fixed config surface
|
||||
// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor.
|
||||
|
||||
const TABS = [
|
||||
{ id: 'local', label: 'Local Accounts' },
|
||||
{ id: 'google', label: 'Google' },
|
||||
{ id: 'discord', label: 'Discord' },
|
||||
{ id: 'custom', label: 'Custom Providers' },
|
||||
]
|
||||
|
||||
// The redirect/callback URL to register with the provider. Mirrors the server's
|
||||
// redirect_uri (APP_BASE_URL + this path); shown so admins can copy it exactly.
|
||||
function callbackUrl(id) {
|
||||
return `${window.location.origin}/api/v1/auth/sso/${id}/callback`
|
||||
}
|
||||
|
||||
function HealthWarning({ provider }) {
|
||||
if (!provider || !provider.enabled || provider.health.valid) return null
|
||||
return (
|
||||
<p className="sans" style={{ margin: '4px 0 0', color: '#e0b070', fontSize: '0.82rem', lineHeight: 1.5 }}>
|
||||
Enabled but incomplete (missing: {provider.health.missing.join(', ')}). Hidden from the login
|
||||
page until fully configured.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function CallbackHint({ id }) {
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span className="field-label">Redirect / callback URL (register this with the provider)</span>
|
||||
<code
|
||||
className="sans"
|
||||
style={{ display: 'block', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--line)', background: 'var(--bg-deep)', color: 'var(--muted)', fontSize: '0.82rem', wordBreak: 'break-all' }}
|
||||
>
|
||||
{callbackUrl(id)}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Built-in (Google / Discord) config form ────────────────────────────────
|
||||
function BuiltinForm({ provider, onSaved }) {
|
||||
const [enabled, setEnabled] = useState(provider.enabled)
|
||||
const [clientId, setClientId] = useState(provider.clientId || '')
|
||||
const [secret, setSecret] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Re-sync when switching between provider tabs.
|
||||
useEffect(() => {
|
||||
setEnabled(provider.enabled)
|
||||
setClientId(provider.clientId || '')
|
||||
setSecret('')
|
||||
setMsg('')
|
||||
setError('')
|
||||
}, [provider.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const body = { enabled, clientId }
|
||||
if (secret) body.secret = secret // only send a new secret when entered
|
||||
await api.admin.updateAuthProvider(provider.id, body)
|
||||
setSecret('')
|
||||
setMsg('Saved.')
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ display: 'inline-flex', width: 26, height: 26 }}>
|
||||
<ProviderIcon icon={provider.kind} size={26} />
|
||||
</span>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
|
||||
<HealthWarning provider={provider} />
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Client ID</span>
|
||||
<input type="text" value={clientId} onChange={(e) => setClientId(e.target.value)} className="input" autoComplete="off" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Client Secret</span>
|
||||
<input
|
||||
type="password"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Client secret'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<CallbackHint id={provider.id} />
|
||||
|
||||
<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>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Local accounts (informational) ─────────────────────────────────────────
|
||||
function LocalInfo() {
|
||||
return (
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Local accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Username & password sign-in (with optional TOTP two-factor) is always enabled and cannot
|
||||
be turned off — it is how you manage accounts and link SSO identities. Manage users under
|
||||
<strong> Users</strong>, and your own two-factor under <strong>Account</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Custom OIDC/OAuth2 providers ────────────────────────────────────────────
|
||||
const EMPTY_CUSTOM = {
|
||||
id: '', name: '', kind: 'oidc', enabled: false, clientId: '', secret: '',
|
||||
authorizeUrl: '', tokenUrl: '', userinfoUrl: '', scopes: 'openid email profile', priority: 100,
|
||||
}
|
||||
|
||||
function CustomEditor({ initial, onDone, onCancel }) {
|
||||
const isNew = !initial.id
|
||||
const [f, setF] = useState(isNew ? EMPTY_CUSTOM : { ...initial, secret: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value }))
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const body = {
|
||||
name: f.name, kind: f.kind, enabled: f.enabled, clientId: f.clientId,
|
||||
authorizeUrl: f.authorizeUrl, tokenUrl: f.tokenUrl, userinfoUrl: f.userinfoUrl,
|
||||
scopes: f.scopes, priority: Number(f.priority) || 100,
|
||||
}
|
||||
if (f.secret) body.secret = f.secret
|
||||
if (isNew) await api.admin.createAuthProvider({ id: f.id, ...body })
|
||||
else await api.admin.updateAuthProvider(initial.id, body)
|
||||
await onDone()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save provider.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 20, marginTop: 16, display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 640 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||
{isNew ? 'Add custom provider' : `Edit ${initial.name}`}
|
||||
</h3>
|
||||
{isNew && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">ID (slug)</span>
|
||||
<input className="input" value={f.id} onChange={set('id')} placeholder="authentik" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Type</span>
|
||||
<select className="input" value={f.kind} onChange={set('kind')}>
|
||||
<option value="oidc">OIDC</option>
|
||||
<option value="oauth2">OAuth2</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label>
|
||||
<span className="field-label">Display name</span>
|
||||
<input className="input" value={f.name} onChange={set('name')} placeholder="Authentik" />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Client ID</span>
|
||||
<input className="input" value={f.clientId} onChange={set('clientId')} autoComplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Client Secret</span>
|
||||
<input className="input" type="password" value={f.secret} onChange={set('secret')} autoComplete="new-password" placeholder={!isNew && initial.hasSecret ? '•••• leave blank to keep' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">Authorization URL</span>
|
||||
<input className="input" value={f.authorizeUrl} onChange={set('authorizeUrl')} placeholder="https://idp.example/application/o/authorize/" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Token URL</span>
|
||||
<input className="input" value={f.tokenUrl} onChange={set('tokenUrl')} placeholder="https://idp.example/application/o/token/" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">UserInfo URL</span>
|
||||
<input className="input" value={f.userinfoUrl} onChange={set('userinfoUrl')} placeholder="https://idp.example/application/o/userinfo/" />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Scopes</span>
|
||||
<input className="input" value={f.scopes} onChange={set('scopes')} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Priority</span>
|
||||
<input className="input" type="number" value={f.priority} onChange={set('priority')} />
|
||||
</label>
|
||||
</div>
|
||||
<Toggle checked={f.enabled} onChange={(v) => setF((p) => ({ ...p, enabled: v }))} label="Enabled" />
|
||||
{!isNew && <CallbackHint id={initial.id} />}
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save provider'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="pill">Cancel</button>
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomProviders({ items, onChanged }) {
|
||||
const [editing, setEditing] = useState(null) // null | 'new' | provider
|
||||
|
||||
async function del(p) {
|
||||
if (!window.confirm(`Delete provider "${p.name}"? This cannot be undone.`)) return
|
||||
await api.admin.deleteAuthProvider(p.id)
|
||||
await onChanged()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12, flexWrap: 'wrap' }}>
|
||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
OAuth2 / OIDC providers (Authentik, Keycloak, Okta, Azure AD, Zitadel, …)
|
||||
</p>
|
||||
{!editing && (
|
||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">+ Add provider</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !editing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem' }}>No custom providers yet.</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td>
|
||||
<td className="adm-td dim">{p.kind}</td>
|
||||
<td className="adm-td">
|
||||
{p.enabled && p.health.valid ? (
|
||||
<span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
|
||||
) : p.enabled ? (
|
||||
<span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
|
||||
) : (
|
||||
<span className="sans dim">Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span>
|
||||
<span className="link-accent" onClick={() => del(p)} style={{ marginLeft: 14, color: '#d98b84' }}>Delete</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<CustomEditor
|
||||
initial={editing === 'new' ? {} : editing}
|
||||
onCancel={() => setEditing(null)}
|
||||
onDone={async () => {
|
||||
setEditing(null)
|
||||
await onChanged()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuthProvidersAdmin() {
|
||||
const [providers, setProviders] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [tab, setTab] = useState('local')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setProviders(await api.admin.listAuthProviders())
|
||||
} catch {
|
||||
setError('Could not load authentication providers.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!providers) return <Loading />
|
||||
|
||||
const byId = (id) => providers.find((p) => p.id === id)
|
||||
const customs = providers.filter((p) => !p.builtin)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', gap: 6, borderBottom: '1px solid var(--line-soft)', marginBottom: 24, flexWrap: 'wrap' }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className="sans"
|
||||
style={{
|
||||
padding: '9px 16px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
color: tab === t.id ? 'var(--head)' : 'var(--muted)',
|
||||
borderBottom: `2px solid ${tab === t.id ? 'var(--accent)' : 'transparent'}`,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'local' && <LocalInfo />}
|
||||
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
|
||||
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
|
||||
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Read-only visibility into the botScore middleware: who is currently banned and
|
||||
// a feed of recent scoring events. The only action is an emergency unban for
|
||||
// false positives — there is no ban/adjust-weights surface here by design.
|
||||
const mono = { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.82rem' }
|
||||
|
||||
export default function BotActivityAdmin() {
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const { loading, error, data } = useAsync(() => api.admin.botActivity(), [tick])
|
||||
const [busyIp, setBusyIp] = useState('')
|
||||
|
||||
const ips = data?.ips || []
|
||||
const events = data?.events || []
|
||||
const banned = ips.filter((e) => e.banned)
|
||||
|
||||
async function unban(ip) {
|
||||
if (!window.confirm(`Unban ${ip}? This clears its score and ban immediately.`)) return
|
||||
setBusyIp(ip)
|
||||
try {
|
||||
await api.admin.unbanIp(ip)
|
||||
reload()
|
||||
} catch {
|
||||
// Surface nothing intrusive; a reload will re-fetch true state either way.
|
||||
reload()
|
||||
} finally {
|
||||
setBusyIp('')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load bot activity." />
|
||||
|
||||
return (
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
|
||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
Live, in-memory scoring and ban state from the bot-protection middleware. State resets
|
||||
when the server restarts.
|
||||
</p>
|
||||
|
||||
{/* Currently banned IPs */}
|
||||
<div>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
||||
Currently banned{banned.length > 0 ? ` (${banned.length})` : ''}
|
||||
</h2>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">IP</th>
|
||||
<th className="adm-th">Score</th>
|
||||
<th className="adm-th">Banned until</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{banned.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>
|
||||
No IPs are currently banned.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{banned.map((e) => (
|
||||
<tr key={e.ip}>
|
||||
<td className="adm-td" style={{ ...mono, color: 'var(--head)' }}>
|
||||
{e.ip}
|
||||
</td>
|
||||
<td className="adm-td">{e.score}</td>
|
||||
<td className="adm-td dim">{dateTime(e.bannedUntil)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
onClick={() => unban(e.ip)}
|
||||
disabled={busyIp === e.ip}
|
||||
className="btn btn-sq"
|
||||
style={{ borderColor: '#d98b84', color: '#d98b84', padding: '5px 12px', fontSize: '0.82rem' }}
|
||||
>
|
||||
{busyIp === e.ip ? 'Unbanning…' : 'Unban'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent scoring events */}
|
||||
<div>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
||||
Recent events
|
||||
</h2>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">When</th>
|
||||
<th className="adm-th">IP</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
<th className="adm-th">Path</th>
|
||||
<th className="adm-th">Points</th>
|
||||
<th className="adm-th">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>
|
||||
No events recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{events.map((ev, i) => (
|
||||
<tr key={`${ev.ts}-${ev.ip}-${i}`}>
|
||||
<td className="adm-td dim">{dateTime(ev.ts)}</td>
|
||||
<td className="adm-td" style={{ ...mono, color: 'var(--text)' }}>
|
||||
{ev.ip}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<span style={{ ...mono, color: ev.type === 'ban' ? '#d98b84' : 'var(--accent)' }}>
|
||||
{ev.reason}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ ...mono, wordBreak: 'break-all' }}>
|
||||
{ev.path || '—'}
|
||||
</td>
|
||||
<td className="adm-td dim">{ev.points ? `+${ev.points}` : '—'}</td>
|
||||
<td className="adm-td">{ev.score}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
151
client/src/routes/admin/views/DiscordBotAdmin.jsx
Normal file
151
client/src/routes/admin/views/DiscordBotAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,17 @@ const round2 = (v) => Math.round(v * 100) / 100
|
||||
const genId = () => (crypto.randomUUID ? crypto.randomUUID() : `el-${Date.now()}-${Math.random()}`)
|
||||
const hexOf = (v) => (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v || '') ? v : '#ffffff')
|
||||
|
||||
// Soft page-weight warning shown before uploading a large hero image. This is
|
||||
// only a nudge (hero images render on the public landing page); the server hard-
|
||||
// limits uploads at 8 MB. Returns true when the caller should abort the upload.
|
||||
const WARN_UPLOAD_MB = 5
|
||||
function tooLargeToUpload(size) {
|
||||
return (
|
||||
size > WARN_UPLOAD_MB * 1024 * 1024 &&
|
||||
!confirm(`This image is over ${WARN_UPLOAD_MB} MB and may slow the page. Upload anyway?`)
|
||||
)
|
||||
}
|
||||
|
||||
function newElement(type, z) {
|
||||
const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' }
|
||||
if (type === 'text_block') {
|
||||
@@ -28,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)
|
||||
@@ -179,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)
|
||||
@@ -188,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)
|
||||
@@ -206,7 +241,7 @@ export default function HeroEditor() {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
if (file.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) return
|
||||
if (tooLargeToUpload(file.size)) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const { url } = await api.admin.upload(file)
|
||||
@@ -313,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>
|
||||
|
||||
@@ -499,9 +534,45 @@ const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(-
|
||||
|
||||
function MoonPanel({ element, onProps }) {
|
||||
const p = element.props || {}
|
||||
const [up, setUp] = useState(false)
|
||||
// Reuses the shared admin upload endpoint (same as the image/background panels);
|
||||
// a successful upload just points props.src at the returned URL.
|
||||
async function onFile(e) {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
if (tooLargeToUpload(f.size)) return
|
||||
setUp(true)
|
||||
try {
|
||||
const { url } = await api.admin.upload(f)
|
||||
onProps({ src: url })
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setUp(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Uses the moon from the hero artwork.</p>
|
||||
<div>
|
||||
<span className="field-label">Moon image</span>
|
||||
{p.src ? (
|
||||
<img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />
|
||||
) : (
|
||||
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default moon from the hero artwork.</p>
|
||||
)}
|
||||
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
|
||||
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'}
|
||||
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
||||
</label>
|
||||
{p.src && (
|
||||
<span className="link-accent" style={{ fontSize: '0.8rem', marginLeft: 10 }} onClick={() => onProps({ src: '' })}>Use default</span>
|
||||
)}
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">Alt text</span>
|
||||
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Size — {p.size || 96}px</span>
|
||||
<input type="range" min="24" max="320" step="1" value={p.size || 96} onChange={(e) => onProps({ size: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||
@@ -547,7 +618,7 @@ function ImagePanel({ element, onProps }) {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
if (f.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) return
|
||||
if (tooLargeToUpload(f.size)) return
|
||||
setUp(true)
|
||||
try {
|
||||
const { url } = await api.admin.upload(f)
|
||||
|
||||
330
client/src/routes/admin/views/Moderation.jsx
Normal file
330
client/src/routes/admin/views/Moderation.jsx
Normal 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)' }
|
||||
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
245
client/src/routes/admin/views/ModerationUser.jsx
Normal 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 user’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -23,6 +27,45 @@ JWT_EXPIRES_IN=1d
|
||||
COOKIE_SECURE=auto
|
||||
COOKIE_NAME=uomm_token
|
||||
|
||||
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
||||
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
||||
# insecure key is derived from JWT_SECRET if unset (with a warning).
|
||||
SECRET_ENC_KEY=dev-only-change-me-too
|
||||
|
||||
# Public base URL of this app, used to build the OAuth redirect_uri
|
||||
# (${APP_BASE_URL}/api/v1/auth/sso/:provider/callback). Set this in production so
|
||||
# the callback URL matches what you register with Google/Discord. If unset, it is
|
||||
# derived from the incoming request (fine for local dev).
|
||||
APP_BASE_URL=http://localhost:5173
|
||||
|
||||
# Short-lived mobile access token lifetime + refresh token lifetime (Part 2).
|
||||
MOBILE_ACCESS_TTL=15m
|
||||
MOBILE_REFRESH_TTL_DAYS=30
|
||||
|
||||
# Reverse-proxy trust. Request path: client -> Pangolin -> newt agent "ptero"
|
||||
# (separate VM) -> this app. ptero is the hop that connects to us, so pin
|
||||
# TRUST_PROXY to ptero's LAN IP: Express then honours X-Forwarded-For ONLY on
|
||||
# connections from ptero, and req.ip / req.secure reflect the real client (used
|
||||
# by rate limiting, backoff, bot-ban, activity log).
|
||||
# <ptero LAN IP> -> e.g. 10.0.0.42 (RECOMMENDED in prod; requires a static
|
||||
# DHCP reservation for ptero in Omada — a lease change would
|
||||
# silently break IP trust)
|
||||
# an integer -> that many hops (fallback if you can't pin an IP)
|
||||
# false -> no proxy (direct connections)
|
||||
# NOTE: a blanket "true" is intentionally rejected (coerced to 1) — it would let
|
||||
# clients spoof their IP via a forged X-Forwarded-For and dodge rate limits/bans.
|
||||
TRUST_PROXY=1
|
||||
|
||||
# Set to 1 to log each request's raw peer address + X-Forwarded-For + resolved
|
||||
# req.ip, so you can verify/refresh ptero's IP without redeploying. Noisy —
|
||||
# leave off in normal operation.
|
||||
DEBUG_TRUST_PROXY=0
|
||||
|
||||
# Optional TOTP two-factor (opt-in per user).
|
||||
TOTP_ISSUER=UOMysticmoon
|
||||
# How long the "password verified, awaiting code" step stays valid.
|
||||
TOTP_CHALLENGE_TTL=5m
|
||||
|
||||
# Created on first boot if the users table is empty
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
@@ -34,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
|
||||
|
||||
@@ -6,7 +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;
|
||||
@@ -117,11 +122,344 @@ CREATE TABLE IF NOT EXISTS activity_log (
|
||||
INDEX idx_activity_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in
|
||||
-- providers ('google', 'discord') once an admin configures them, plus any custom
|
||||
-- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED
|
||||
-- (client_secret_enc) and are never returned to a client. Built-in providers
|
||||
-- hardcode their endpoint URLs in code; the *_url columns are used only by
|
||||
-- custom (oidc/oauth2) providers.
|
||||
CREATE TABLE IF NOT EXISTS auth_providers (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
|
||||
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
client_id VARCHAR(255) NULL,
|
||||
client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
|
||||
authorize_url VARCHAR(500) NULL, -- custom providers only
|
||||
token_url VARCHAR(500) NULL,
|
||||
userinfo_url VARCHAR(500) NULL,
|
||||
scopes VARCHAR(500) NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Account linking: maps an external SSO identity to an internal user. A login via
|
||||
-- SSO succeeds only if a matching (provider, subject) row exists (link-only —
|
||||
-- external identities are never auto-provisioned into accounts). UNIQUE(provider,
|
||||
-- subject) guarantees one external identity maps to exactly one internal user.
|
||||
CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
|
||||
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
|
||||
email VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_identity_provider_subject (provider, subject),
|
||||
INDEX idx_identity_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth.
|
||||
-- The opaque refresh token is never stored in the clear — only its sha256 hash —
|
||||
-- so a DB read does not leak usable tokens. Rows are rotated on every refresh
|
||||
-- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions
|
||||
-- do NOT use this table; it is purely for the mobile bearer flow.
|
||||
CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
||||
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_mrt_user (user_id),
|
||||
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.
|
||||
-- (The category foreign key is only added on fresh installs; on upgraded databases
|
||||
-- referential integrity for category_id is enforced in application code.)
|
||||
-- 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;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
||||
|
||||
550
server/package-lock.json
generated
550
server/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"express-slow-down": "^3.1.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -22,12 +23,23 @@
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.1",
|
||||
"nodemailer": "^9.0.1",
|
||||
"sanitize-html": "^2.17.5"
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.17.5",
|
||||
"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",
|
||||
@@ -56,6 +68,43 @@
|
||||
"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",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -92,6 +141,12 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base32.js": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
@@ -240,6 +295,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -265,6 +329,42 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"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",
|
||||
@@ -361,6 +461,15 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -398,6 +507,12 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -506,6 +621,12 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -645,6 +766,39 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-slow-down": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express-slow-down/-/express-slow-down-3.1.0.tgz",
|
||||
"integrity": "sha512-0gZ1HHow8H83z1/+81DdWB60RSGHI0mJB0ZM1m5P6/BexORFcA8P1TgU0NKEwOiRmxyCIoktZYqmA+1UCc83+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express-rate-limit": "8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "4 || 5 || ^5.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/express-slow-down/node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||
@@ -689,6 +843,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -707,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",
|
||||
@@ -731,6 +905,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -768,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",
|
||||
@@ -781,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",
|
||||
@@ -894,12 +1130,33 @@
|
||||
"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",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -932,6 +1189,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -964,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",
|
||||
@@ -1022,6 +1301,18 @@
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -1383,6 +1674,52 @@
|
||||
"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",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
@@ -1398,6 +1735,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"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",
|
||||
@@ -1423,6 +1779,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -1471,6 +1836,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -1538,6 +1920,21 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1636,6 +2033,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -1736,6 +2139,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/speakeasy": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base32.js": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1762,6 +2177,32 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -1775,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",
|
||||
@@ -1880,6 +2358,74 @@
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"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",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js",
|
||||
"seed": "node db/seed.js",
|
||||
"test": "echo \"no tests yet\" && exit 0"
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [
|
||||
"express",
|
||||
@@ -24,6 +25,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"express-slow-down": "^3.1.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -31,9 +33,13 @@
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.1",
|
||||
"nodemailer": "^9.0.1",
|
||||
"sanitize-html": "^2.17.5"
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.17.5",
|
||||
"speakeasy": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4"
|
||||
"nodemon": "^3.1.4",
|
||||
"swagger-autogen": "^2.23.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,32 @@ 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')
|
||||
const botScore = require('./middleware/botScore')
|
||||
|
||||
const httpLog = createLogger('http')
|
||||
const errLog = createLogger('error')
|
||||
|
||||
const app = express()
|
||||
|
||||
// Behind Pangolin: trust the first proxy so req.secure (for the cookie flag),
|
||||
// req.ip (activity log / rate limiting) reflect the X-Forwarded-* headers.
|
||||
app.set('trust proxy', 1)
|
||||
// Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and
|
||||
// req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client
|
||||
// from X-Forwarded-*. Configurable via TRUST_PROXY; defaults to a single hop and
|
||||
// never a blanket `true` (which would let clients spoof their IP). Must run
|
||||
// before any middleware that reads req.ip.
|
||||
applyTrustProxy(app)
|
||||
|
||||
// Optional trust-proxy diagnostics (off unless DEBUG_TRUST_PROXY is set). Before
|
||||
// the bot guard so it logs scanner/junk source IPs too.
|
||||
app.use(trustProxyDebug)
|
||||
|
||||
// Bot / scanner guard — mounted first (before helmet/routing) so banned IPs and
|
||||
// obvious scanner probes are 404'd immediately without reaching real handlers.
|
||||
app.use(botScore.guard)
|
||||
|
||||
// Security headers. CSP is left off here and will be tuned for the React SPA in
|
||||
// the frontend phase; the rest of helmet's protections stay enabled.
|
||||
@@ -62,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' }))
|
||||
|
||||
|
||||
65
server/src/auth/providers/base.provider.js
Normal file
65
server/src/auth/providers/base.provider.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ── Auth provider contract (base) ──────────────────────────────────────────
|
||||
//
|
||||
// The abstract interface every auth provider implements. Concrete providers:
|
||||
// - local → username/password (LocalProvider, unchanged live flow)
|
||||
// - google, discord, generic OIDC → OAuth2Provider subclasses
|
||||
//
|
||||
// A provider config (a row from auth_providers, or a built-in default) looks like:
|
||||
// { id, kind, name, enabled, clientId, clientSecret,
|
||||
// authorizeUrl, tokenUrl, userinfoUrl, scopes, priority }
|
||||
//
|
||||
// Interface (per the Part 3 spec). OAuth providers implement the SSO-flow methods;
|
||||
// LocalProvider implements authenticate(). Anything not applicable stays a throw.
|
||||
|
||||
class BaseProvider {
|
||||
constructor(config = {}) {
|
||||
this.config = config
|
||||
this.id = config.id || config.kind || 'base'
|
||||
this.name = config.name || this.id
|
||||
this.kind = config.kind || 'base'
|
||||
this.type = this.kind // legacy alias
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return Boolean(this.config.enabled)
|
||||
}
|
||||
|
||||
// Direct-credential auth (local providers). Resolve to an internal user or null.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async authenticate(credentials) {
|
||||
throw new Error(`authenticate() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Begin an SSO redirect flow: the provider's authorization URL.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
getAuthorizationUrl(state, options) {
|
||||
throw new Error(`getAuthorizationUrl() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Complete an SSO redirect flow: exchange the callback code for a normalized
|
||||
// user profile ({ subject, email, name }).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async handleCallback(params) {
|
||||
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Fetch the raw external profile using an access token.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async getUserProfile(accessToken) {
|
||||
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Normalize a raw external profile to { subject, email, name }.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
mapUser(profile) {
|
||||
throw new Error(`mapUser() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Link an external identity to an internal user (shared by OAuth2Provider).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async linkAccount(user, profile) {
|
||||
throw new Error(`linkAccount() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseProvider
|
||||
30
server/src/auth/providers/discord.provider.js
Normal file
30
server/src/auth/providers/discord.provider.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Built-in Discord provider (OAuth2). Endpoints hardcoded — admins configure only
|
||||
// Enabled + Client ID + Client Secret. `identify` yields the stable user id;
|
||||
// `email` yields the address. Discord's id is the stable per-user subject.
|
||||
|
||||
const OAuth2Provider = require('./oauth2.provider')
|
||||
|
||||
class DiscordProvider extends OAuth2Provider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: 'discord', name: 'Discord', ...config, id: config.id || 'discord' })
|
||||
}
|
||||
|
||||
authEndpoint() {
|
||||
return 'https://discord.com/oauth2/authorize'
|
||||
}
|
||||
tokenEndpoint() {
|
||||
return 'https://discord.com/api/oauth2/token'
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
return 'https://discord.com/api/users/@me'
|
||||
}
|
||||
scopeString() {
|
||||
return 'identify email'
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
// global_name is the new display name; fall back to the legacy username.
|
||||
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DiscordProvider
|
||||
38
server/src/auth/providers/genericOidc.provider.js
Normal file
38
server/src/auth/providers/genericOidc.provider.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Generic, fully-configurable OAuth2 / OIDC provider for custom IdPs (Authentik,
|
||||
// Keycloak, Okta, Azure AD, Zitadel, …). Unlike the built-ins, its endpoints and
|
||||
// scopes come from the stored config. Profile mapping follows OIDC conventions
|
||||
// with sensible fallbacks for plain OAuth2 userinfo shapes.
|
||||
|
||||
const OAuth2Provider = require('./oauth2.provider')
|
||||
|
||||
class GenericOidcProvider extends OAuth2Provider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: config.kind || 'oidc', ...config })
|
||||
this.authorizeUrl = config.authorizeUrl ?? config.authorize_url ?? null
|
||||
this.tokenUrl = config.tokenUrl ?? config.token_url ?? null
|
||||
this.userinfoUrl = config.userinfoUrl ?? config.userinfo_url ?? null
|
||||
this.scopes = config.scopes || 'openid email profile'
|
||||
}
|
||||
|
||||
authEndpoint() {
|
||||
return this.authorizeUrl
|
||||
}
|
||||
tokenEndpoint() {
|
||||
return this.tokenUrl
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
return this.userinfoUrl
|
||||
}
|
||||
scopeString() {
|
||||
return this.scopes
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
return {
|
||||
subject: p.sub || p.id || p.user_id || p.uid || null,
|
||||
email: p.email || null,
|
||||
name: p.name || p.preferred_username || p.username || p.email || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GenericOidcProvider
|
||||
34
server/src/auth/providers/google.provider.js
Normal file
34
server/src/auth/providers/google.provider.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Built-in Google provider (OAuth2 / OpenID Connect). Endpoints are hardcoded —
|
||||
// admins configure only Enabled + Client ID + Client Secret. Uses the OIDC
|
||||
// userinfo endpoint; `sub` is Google's stable per-user id.
|
||||
|
||||
const OAuth2Provider = require('./oauth2.provider')
|
||||
|
||||
class GoogleProvider extends OAuth2Provider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: 'google', name: 'Google', ...config, id: config.id || 'google' })
|
||||
}
|
||||
|
||||
authEndpoint() {
|
||||
return 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
}
|
||||
tokenEndpoint() {
|
||||
return 'https://oauth2.googleapis.com/token'
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
return 'https://openidconnect.googleapis.com/v1/userinfo'
|
||||
}
|
||||
scopeString() {
|
||||
return 'openid email profile'
|
||||
}
|
||||
authParams() {
|
||||
// Online access (no refresh token needed for login), and let the user pick
|
||||
// an account rather than silently reusing a signed-in one.
|
||||
return { access_type: 'online', prompt: 'select_account' }
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GoogleProvider
|
||||
27
server/src/auth/providers/local.provider.js
Normal file
27
server/src/auth/providers/local.provider.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// ── Local (username/password) provider ─────────────────────────────────────
|
||||
//
|
||||
// Reference implementation of the BaseProvider contract for local credential
|
||||
// auth. It delegates to the existing users model, mirroring what auth.controller
|
||||
// does today — but it is NOT wired into the live login flow. The controller
|
||||
// keeps its own login logic (honeypot, bot scoring, TOTP staging, backoff) so
|
||||
// this refactor changes no behavior. This exists so Part 3 can treat "local" as
|
||||
// just another provider alongside SSO, behind one uniform interface.
|
||||
|
||||
const BaseProvider = require('./base.provider')
|
||||
const users = require('../../model/users/users.model')
|
||||
|
||||
class LocalProvider extends BaseProvider {
|
||||
constructor(config = {}) {
|
||||
super({ name: 'local', type: 'local', enabled: true, ...config })
|
||||
}
|
||||
|
||||
// Verify username + password. Returns the raw user row on success, else null.
|
||||
// Callers layer their own throttling/scoring on top (as auth.controller does).
|
||||
async authenticate({ username, password } = {}) {
|
||||
const user = await users.getRawByUsername(username)
|
||||
const ok = user && (await users.validatePassword(user, password))
|
||||
return ok ? user : null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocalProvider
|
||||
115
server/src/auth/providers/oauth2.provider.js
Normal file
115
server/src/auth/providers/oauth2.provider.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ── Shared OAuth2 / OIDC provider ──────────────────────────────────────────
|
||||
//
|
||||
// Implements the reusable authorization-code + PKCE flow so the concrete
|
||||
// providers (google, discord, generic OIDC) only supply their endpoints, scope,
|
||||
// and a normalizeProfile(). Uses Node's global fetch (no new dependency).
|
||||
//
|
||||
// Flow:
|
||||
// getAuthorizationUrl(state, { redirectUri, codeChallenge }) → redirect the browser
|
||||
// handleCallback({ code, redirectUri, codeVerifier })
|
||||
// → exchangeCode (POST token endpoint) → getUserProfile (GET userinfo)
|
||||
// → mapUser → { subject, email, name }
|
||||
|
||||
const BaseProvider = require('./base.provider')
|
||||
const userIdentities = require('../../model/userIdentities/userIdentities.model')
|
||||
const log = require('../../utils/logger')('sso')
|
||||
|
||||
class OAuth2Provider extends BaseProvider {
|
||||
constructor(config = {}) {
|
||||
super(config)
|
||||
this.clientId = config.clientId ?? config.client_id ?? null
|
||||
this.clientSecret = config.clientSecret ?? config.client_secret ?? null
|
||||
}
|
||||
|
||||
// ── Subclass hooks (endpoints / scope / profile mapping) ──────────────────
|
||||
authEndpoint() {
|
||||
throw new Error(`authEndpoint() not set for provider '${this.id}'`)
|
||||
}
|
||||
tokenEndpoint() {
|
||||
throw new Error(`tokenEndpoint() not set for provider '${this.id}'`)
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
throw new Error(`userinfoEndpoint() not set for provider '${this.id}'`)
|
||||
}
|
||||
scopeString() {
|
||||
return 'openid email profile'
|
||||
}
|
||||
// Extra provider-specific authorize-URL params (e.g. Google's prompt).
|
||||
authParams() {
|
||||
return {}
|
||||
}
|
||||
// Map a raw profile → { subject, email, name }. Subclasses must implement.
|
||||
normalizeProfile(profile) {
|
||||
throw new Error(`normalizeProfile() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// ── Flow ──────────────────────────────────────────────────────────────────
|
||||
getAuthorizationUrl(state, { redirectUri, codeChallenge } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId || '',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: this.scopeString(),
|
||||
state,
|
||||
})
|
||||
if (codeChallenge) {
|
||||
params.set('code_challenge', codeChallenge)
|
||||
params.set('code_challenge_method', 'S256')
|
||||
}
|
||||
for (const [k, v] of Object.entries(this.authParams())) params.set(k, v)
|
||||
return `${this.authEndpoint()}?${params.toString()}`
|
||||
}
|
||||
|
||||
async handleCallback({ code, redirectUri, codeVerifier } = {}) {
|
||||
const tokenSet = await this.exchangeCode({ code, redirectUri, codeVerifier })
|
||||
const profile = await this.getUserProfile(tokenSet.access_token)
|
||||
return this.mapUser(profile)
|
||||
}
|
||||
|
||||
async exchangeCode({ code, redirectUri, codeVerifier }) {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: this.clientId || '',
|
||||
client_secret: this.clientSecret || '',
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
const res = await fetch(this.tokenEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
log.warn('token exchange failed', { provider: this.id, status: res.status })
|
||||
throw new Error(`token exchange failed (${res.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async getUserProfile(accessToken) {
|
||||
const res = await fetch(this.userinfoEndpoint(), {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
log.warn('userinfo fetch failed', { provider: this.id, status: res.status })
|
||||
throw new Error(`userinfo failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
mapUser(profile) {
|
||||
const mapped = this.normalizeProfile(profile)
|
||||
if (!mapped || !mapped.subject) throw new Error(`provider '${this.id}' returned no subject`)
|
||||
return mapped
|
||||
}
|
||||
|
||||
// Persist the external → internal user link. Shared by every OAuth provider.
|
||||
async linkAccount(user, profile) {
|
||||
const p = this.mapUser(profile)
|
||||
return userIdentities.link({ userId: user.id, provider: this.id, subject: p.subject, email: p.email })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth2Provider
|
||||
128
server/src/auth/providers/registry.js
Normal file
128
server/src/auth/providers/registry.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// ── Provider registry ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Turns stored auth_providers rows into live provider instances, and owns the
|
||||
// "which providers are usable" health logic. The authentication layer talks to
|
||||
// the registry, never to a specific provider class, so adding a provider is just
|
||||
// a new entry in KINDS.
|
||||
//
|
||||
// Built-ins (google, discord) always "exist" as defaults even before an admin
|
||||
// creates a row, so the admin UI can render their config form. A provider is only
|
||||
// shown to end users (login page) when it is enabled AND its config validates.
|
||||
|
||||
const GoogleProvider = require('./google.provider')
|
||||
const DiscordProvider = require('./discord.provider')
|
||||
const GenericOidcProvider = require('./genericOidc.provider')
|
||||
const authProviders = require('../../model/authProviders/authProviders.model')
|
||||
|
||||
// kind → provider class.
|
||||
const KINDS = {
|
||||
google: GoogleProvider,
|
||||
discord: DiscordProvider,
|
||||
oidc: GenericOidcProvider,
|
||||
oauth2: GenericOidcProvider,
|
||||
}
|
||||
|
||||
// Built-in providers and their fixed display metadata. Endpoints are in the
|
||||
// provider classes; only enabled/clientId/secret are admin-configurable.
|
||||
const BUILTINS = [
|
||||
{ id: 'google', kind: 'google', name: 'Google', priority: 1 },
|
||||
{ id: 'discord', kind: 'discord', name: 'Discord', priority: 2 },
|
||||
]
|
||||
|
||||
const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id))
|
||||
|
||||
function isBuiltin(id) {
|
||||
return BUILTIN_IDS.has(id)
|
||||
}
|
||||
|
||||
// Instantiate a provider from a config row (secret already decrypted by the
|
||||
// model as `client_secret`). Returns null for an unknown kind.
|
||||
function instantiate(row) {
|
||||
const Klass = KINDS[row.kind]
|
||||
if (!Klass) return null
|
||||
return new Klass({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
clientId: row.client_id,
|
||||
clientSecret: row.client_secret, // present only via getWithSecret
|
||||
authorizeUrl: row.authorize_url,
|
||||
tokenUrl: row.token_url,
|
||||
userinfoUrl: row.userinfo_url,
|
||||
scopes: row.scopes,
|
||||
priority: row.priority,
|
||||
})
|
||||
}
|
||||
|
||||
// Load a ready-to-use provider instance (secret decrypted) by id, or null.
|
||||
async function load(id) {
|
||||
const row = await authProviders.getWithSecret(id)
|
||||
if (!row) return null
|
||||
return instantiate(row)
|
||||
}
|
||||
|
||||
// Validate a config row's completeness. Built-ins need client_id + a secret;
|
||||
// custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }.
|
||||
function validateConfig(row) {
|
||||
const missing = []
|
||||
if (!row.client_id) missing.push('client_id')
|
||||
// A stored secret shows up as client_secret_enc on plain rows, or client_secret
|
||||
// on decrypted rows — accept either as "has a secret".
|
||||
if (!row.client_secret_enc && !row.client_secret) missing.push('client_secret')
|
||||
if (row.kind === 'oidc' || row.kind === 'oauth2') {
|
||||
if (!row.authorize_url) missing.push('authorize_url')
|
||||
if (!row.token_url) missing.push('token_url')
|
||||
if (!row.userinfo_url) missing.push('userinfo_url')
|
||||
}
|
||||
return { valid: missing.length === 0, missing }
|
||||
}
|
||||
|
||||
// All configured rows merged with built-in defaults (so google/discord always
|
||||
// appear for the admin UI even with no row yet). Each entry carries health.
|
||||
async function listConfigured() {
|
||||
const rows = await authProviders.list()
|
||||
const byId = new Map(rows.map((r) => [r.id, r]))
|
||||
const out = []
|
||||
// Built-ins first, in their fixed order.
|
||||
for (const b of BUILTINS) {
|
||||
const row = byId.get(b.id) || {
|
||||
id: b.id, kind: b.kind, name: b.name, enabled: 0,
|
||||
client_id: null, client_secret_enc: null, priority: b.priority,
|
||||
}
|
||||
byId.delete(b.id)
|
||||
out.push({ ...row, builtin: true, health: validateConfig(row) })
|
||||
}
|
||||
// Then any custom providers.
|
||||
for (const row of byId.values()) {
|
||||
out.push({ ...row, builtin: false, health: validateConfig(row) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Providers that should appear to end users: enabled AND valid. Shaped for the
|
||||
// public discovery endpoint and sorted by priority.
|
||||
async function listEnabledValid() {
|
||||
const configured = await listConfigured()
|
||||
return configured
|
||||
.filter((p) => p.enabled && validateConfig(p).valid)
|
||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2'
|
||||
loginUrl: `/api/v1/auth/sso/${p.id}/start`,
|
||||
priority: p.priority ?? 100,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
BUILTINS,
|
||||
isBuiltin,
|
||||
instantiate,
|
||||
load,
|
||||
validateConfig,
|
||||
listConfigured,
|
||||
listEnabledValid,
|
||||
}
|
||||
89
server/src/auth/session.middleware.js
Normal file
89
server/src/auth/session.middleware.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// ── Session middleware ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Express middleware built on the session service. Three pieces:
|
||||
//
|
||||
// attachSession — best-effort: decorate the request with session info if a
|
||||
// valid token is present, but never reject. For routes that
|
||||
// behave differently for anon vs authed callers.
|
||||
// requireAuth — the gate for protected routes. Preserves the exact behavior
|
||||
// of the old isLoggedIn: re-validate the user against the DB on
|
||||
// every request so a demoted/deleted user loses access
|
||||
// immediately, and set req.user to the fresh DB row.
|
||||
// requireRole — role gate factory, unchanged from the original.
|
||||
|
||||
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.
|
||||
function attachSession(req, res, next) {
|
||||
const session = sessionService.validateSession(req)
|
||||
if (session) {
|
||||
req.session = session
|
||||
req.authMethod = session.authMethod
|
||||
req.sessionMeta = sessionService.sessionMeta(req)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// Gate middleware for protected (admin) routes. Re-validates the token against
|
||||
// the database on every request so a demoted or deleted user loses access
|
||||
// immediately, instead of keeping their old role (or a working session) until
|
||||
// the JWT expires. req.user carries the fresh DB row, not the token payload.
|
||||
async function requireAuth(req, res, next) {
|
||||
const session = sessionService.validateSession(req)
|
||||
if (!session) return res.status(401).json({ message: 'Unauthorized' })
|
||||
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
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error('requireAuth', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
|
||||
// first so req.user is populated. Use for admin-only endpoints (users, site
|
||||
// mode, settings) so a lower-privilege editor cannot reach them.
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (roles.includes(req.user?.role)) return next()
|
||||
return res.status(403).json({ message: 'Forbidden' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachSession,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
}
|
||||
267
server/src/auth/session.service.js
Normal file
267
server/src/auth/session.service.js
Normal file
@@ -0,0 +1,267 @@
|
||||
// ── Session service ────────────────────────────────────────────────────────
|
||||
//
|
||||
// The single seam every caller goes through to issue and validate a session.
|
||||
// Today a "session" is a signed JWT (cookie for web, or a Bearer token), but
|
||||
// callers only ever see the abstract Session object below — never the raw token
|
||||
// shape. That indirection is what lets Part 2 (mobile bearer tokens) and Part 3
|
||||
// (SSO) add new `authMethod`s without touching controllers or middleware.
|
||||
//
|
||||
// A Session object:
|
||||
// {
|
||||
// sessionId, // stable id for this session (JWT jti)
|
||||
// userId, // the user's DB id
|
||||
// username,
|
||||
// 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
|
||||
// }
|
||||
//
|
||||
// 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
|
||||
// bearer flow (Part 2); 'google'/'discord'/'oidc' are SSO providers and 'sso' is
|
||||
// the generic fallback label (Part 3). Sessions are tagged by how they were
|
||||
// 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. 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.typ !== SESSION_TYP) return null
|
||||
if (decoded.stage || decoded.kind) return null
|
||||
return {
|
||||
sessionId: decoded.jti || null,
|
||||
userId: decoded.id,
|
||||
username: decoded.username,
|
||||
role: decoded.role,
|
||||
authMethod: decoded.authMethod || 'local',
|
||||
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
||||
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
|
||||
lastSeenAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a real session for a fully-authenticated user. Signs a JWT carrying the
|
||||
// identity claims plus authMethod + a fresh session id (jti), and returns both
|
||||
// the raw token (the caller sets the cookie or returns it as a bearer token)
|
||||
// and the decoded Session object. Does NOT touch cookies or the DB — issuing the
|
||||
// cookie and recording the login stay in the controller so its bot-scoring /
|
||||
// backoff / activity-log orchestration is unchanged.
|
||||
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, 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 }
|
||||
}
|
||||
|
||||
// Issue the short-lived "password verified, awaiting TOTP" challenge. This is
|
||||
// deliberately NOT a session — validateSession rejects it — so a half-completed
|
||||
// login can never be presented as a full one.
|
||||
function createPartialSession(user) {
|
||||
log.info('partial (TOTP) session issued', { userId: user.id, username: user.username })
|
||||
return token.signTotpChallenge(user)
|
||||
}
|
||||
|
||||
// Complete the TOTP step: verify the challenge token and return the decoded
|
||||
// identity ({ id, stage }) so the caller can load the user and createSession().
|
||||
// Returns null for an expired/invalid/non-challenge token.
|
||||
function upgradeSessionAfterTotp(challengeToken) {
|
||||
const decoded = token.verifyTotpChallenge(challengeToken)
|
||||
if (!decoded) {
|
||||
log.warn('TOTP challenge rejected (expired or invalid)')
|
||||
return null
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Validate the session on an incoming request WITHOUT hitting the DB — pure
|
||||
// token verification + identity decode. Returns a Session object or null.
|
||||
// Stage-tagged tokens (the TOTP challenge) are explicitly not sessions.
|
||||
// DB re-validation of the user is a middleware concern (requireAuth), kept
|
||||
// separate so a demoted/deleted user still loses access on the next request.
|
||||
function validateSession(req, now = Date.now()) {
|
||||
const raw = token.extractToken(req)
|
||||
if (!raw) return null
|
||||
return sessionFromDecoded(token.verifyToken(raw), now)
|
||||
}
|
||||
|
||||
// Decode a raw token string into a Session object (or null). Used where the
|
||||
// token is already in hand rather than on a request.
|
||||
function decodeIdentity(rawToken, now = Date.now()) {
|
||||
if (!rawToken) return null
|
||||
return sessionFromDecoded(token.verifyToken(rawToken), now)
|
||||
}
|
||||
|
||||
// ── Mobile (bearer) sessions ───────────────────────────────────────────────
|
||||
// Native clients get a short-lived JWT access token (validated on every request
|
||||
// exactly like a cookie session) plus a long-lived opaque refresh token. The
|
||||
// refresh token is random and never a JWT: it is stored server-side by hash and
|
||||
// is the only revocable half, which is what makes mobile logout meaningful.
|
||||
//
|
||||
// These functions are intentionally pure — they mint and hash but do NOT touch
|
||||
// the database. The controller persists the returned refreshHash via the
|
||||
// mobileSessions model, keeping this module DB-free and unit-testable.
|
||||
|
||||
const MOBILE_ACCESS_TTL = process.env.MOBILE_ACCESS_TTL || '15m'
|
||||
const MOBILE_REFRESH_TTL_DAYS = Number(process.env.MOBILE_REFRESH_TTL_DAYS) || 30
|
||||
|
||||
// Hash a raw refresh token to the value stored in the DB. Exported so the
|
||||
// controller and model agree on the exact representation.
|
||||
function hashRefreshToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Mint a fresh access + refresh pair for a user. `now` is injectable for tests.
|
||||
function mintMobileTokens(user, meta = {}, now = Date.now()) {
|
||||
const sessionId = crypto.randomUUID()
|
||||
const accessToken = token.signToken(
|
||||
user,
|
||||
{ authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
|
||||
{ expiresIn: MOBILE_ACCESS_TTL },
|
||||
)
|
||||
// 256 bits of entropy, url-safe. Opaque — carries no claims.
|
||||
const refreshToken = crypto.randomBytes(32).toString('base64url')
|
||||
const refreshExpiresAt = new Date(now + MOBILE_REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
refreshHash: hashRefreshToken(refreshToken),
|
||||
refreshExpiresAt,
|
||||
expiresIn: MOBILE_ACCESS_TTL,
|
||||
deviceHash: meta.deviceHash || null,
|
||||
userAgent: meta.userAgent || null,
|
||||
session: sessionFromDecoded(token.verifyToken(accessToken), now),
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a mobile session at login.
|
||||
function createMobileSession(user, meta = {}, now = Date.now()) {
|
||||
const out = mintMobileTokens(user, meta, now)
|
||||
log.info('mobile session created', { userId: user.id, username: user.username, sessionId: out.session.sessionId })
|
||||
return out
|
||||
}
|
||||
|
||||
// Rotate a mobile session on refresh — same shape as createMobileSession. The
|
||||
// caller is responsible for having validated + revoked the presented refresh
|
||||
// token before calling this (rotation), and for persisting the new refreshHash.
|
||||
function refreshMobileSession(user, meta = {}, now = Date.now()) {
|
||||
const out = mintMobileTokens(user, meta, now)
|
||||
log.info('mobile session refreshed', { userId: user.id, sessionId: out.session.sessionId })
|
||||
return out
|
||||
}
|
||||
|
||||
// Validate a raw bearer access token → Session object or null. Rejects
|
||||
// stage-tagged tokens (a TOTP challenge is not a bearer session).
|
||||
function validateBearerToken(rawToken, now = Date.now()) {
|
||||
if (!rawToken) return null
|
||||
return sessionFromDecoded(token.verifyToken(rawToken), now)
|
||||
}
|
||||
|
||||
// Optional per-session metadata derived from the request. Attached to the
|
||||
// session object by middleware for logging/auditing; NOT baked into the token
|
||||
// (keeps tokens small and avoids trusting client-supplied device data as a claim).
|
||||
function sessionMeta(req) {
|
||||
const ip = req.ip || null
|
||||
const userAgent = (req.headers && req.headers['user-agent']) || null
|
||||
const deviceHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${userAgent || ''}|${ip || ''}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return { ip, userAgent, deviceHash }
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Alias kept for callers that speak of "invalidating" one session.
|
||||
async function invalidateSession(sessionId, opts) {
|
||||
return revokeSession(sessionId, opts)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUTH_METHODS,
|
||||
createSession,
|
||||
createPartialSession,
|
||||
upgradeSessionAfterTotp,
|
||||
validateSession,
|
||||
decodeIdentity,
|
||||
sessionMeta,
|
||||
revokeSession,
|
||||
invalidateSession,
|
||||
isSessionRevoked,
|
||||
invalidateAllUserSessions,
|
||||
// Mobile bearer sessions.
|
||||
createMobileSession,
|
||||
refreshMobileSession,
|
||||
validateBearerToken,
|
||||
hashRefreshToken,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user