Compare commits
25 Commits
86e44a94a2
...
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 |
@@ -6,6 +6,18 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--prefix", "client"],
|
"runtimeArgs": ["run", "dev", "--prefix", "client"],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "server"],
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bot",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "bot"],
|
||||||
|
"port": 4100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
23
.env.example
23
.env.example
@@ -4,6 +4,10 @@
|
|||||||
# App
|
# App
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
# Separate, UNPUBLISHED port for server<->bot internal traffic (the decrypted
|
||||||
|
# bot-token route). Must match the port in the bot's SITE_INTERNAL_URL
|
||||||
|
# (docker-compose.yml) and must NEVER be published/proxied. See issue #33.
|
||||||
|
INTERNAL_PORT=3001
|
||||||
UPLOAD_DIR=/app/uploads
|
UPLOAD_DIR=/app/uploads
|
||||||
# Logging — written to BOTH the console and a log file.
|
# Logging — written to BOTH the console and a log file.
|
||||||
LOG_LEVEL=info # console verbosity: error | warn | info | debug
|
LOG_LEVEL=info # console verbosity: error | warn | info | debug
|
||||||
@@ -12,7 +16,8 @@ LOG_TO_FILE=true # set false for console-only
|
|||||||
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
||||||
LOG_FILE=app.log
|
LOG_FILE=app.log
|
||||||
|
|
||||||
# Database (the values here are shared by the `db` and `app` containers)
|
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
|
||||||
|
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=uomysticmoon
|
DB_NAME=uomysticmoon
|
||||||
@@ -58,3 +63,19 @@ CONTACT_TO=UOMysticmoon@gmail.com
|
|||||||
|
|
||||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Discord bot — internal API (server <-> bot/, see docker-compose.yml's `bot`
|
||||||
|
# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same
|
||||||
|
# variable in bot/.env.example — it is the only auth on both sides' /internal/*
|
||||||
|
# routes, so a mismatch silently breaks every server<->bot call with 401s.
|
||||||
|
# It also guards the server's /internal/bot-config route, which returns the
|
||||||
|
# DECRYPTED Discord token; with NODE_ENV=production the app REFUSES TO START if
|
||||||
|
# this is left blank, at this placeholder, or shorter than 16 chars. Generate a
|
||||||
|
# long random string. The Discord bot TOKEN itself is not an env var — it's
|
||||||
|
# entered in the admin panel (Discord Bot page) and stored encrypted in the DB.
|
||||||
|
#
|
||||||
|
# Defense in depth: even with a strong key, configure Pangolin/your reverse
|
||||||
|
# proxy to DENY /api/v1/internal (and never forward INTERNAL_PORT). The route no
|
||||||
|
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
||||||
|
BOT_INTERNAL_URL=http://bot:4100
|
||||||
|
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -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)
|
- [First admin & site mode](#first-admin--site-mode)
|
||||||
- [Pages & routes](#pages--routes)
|
- [Pages & routes](#pages--routes)
|
||||||
- [API endpoints](#api-endpoints)
|
- [API endpoints](#api-endpoints)
|
||||||
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
@@ -39,6 +40,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
| Database | MariaDB 11 (own container) |
|
| Database | MariaDB 11 (own container) |
|
||||||
| Frontend | React 18, Vite 5, React Router 6 |
|
| Frontend | React 18, Vite 5, React Router 6 |
|
||||||
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
|
| Email | Nodemailer (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 |
|
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -57,6 +59,7 @@ UOMSITE/
|
|||||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
|
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
|
||||||
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
|
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
|
||||||
│ ├─ db/ schema.sql + seed.js
|
│ ├─ db/ schema.sql + seed.js
|
||||||
|
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
|
||||||
│ └─ .env.example
|
│ └─ .env.example
|
||||||
├─ client/ React + Vite SPA
|
├─ client/ React + Vite SPA
|
||||||
│ ├─ src/
|
│ ├─ src/
|
||||||
@@ -207,7 +210,44 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
|
|
||||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
|
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
||||||
|
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API documentation (Swagger)
|
||||||
|
|
||||||
|
The full API is documented as an **OpenAPI 3.0** spec and served with **Swagger UI**:
|
||||||
|
|
||||||
|
| URL | What |
|
||||||
|
|---|---|
|
||||||
|
| `http://localhost:3000/api/docs` | Interactive Swagger UI (try-it-out, auth) |
|
||||||
|
| `http://localhost:3000/api/docs.json` | Raw OpenAPI 3.0 spec (JSON) |
|
||||||
|
|
||||||
|
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
|
||||||
|
groups) with its summary, parameters, request body, security requirement, and the response codes it
|
||||||
|
actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `429` rate limits, …).
|
||||||
|
|
||||||
|
**Authentication in the UI** — click **Authorize** and provide either:
|
||||||
|
|
||||||
|
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
|
||||||
|
`POST /api/v1/auth/login`), or
|
||||||
|
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
||||||
|
`Authorization: Bearer <token>`).
|
||||||
|
|
||||||
|
**Regenerating the spec** — the spec is generated from `#swagger.*` annotations next to each route
|
||||||
|
(`server/src/router/**`) plus the shared definitions in `server/swagger/swagger.js`
|
||||||
|
([swagger-autogen](https://github.com/davibaltar/swagger-autogen)). The output
|
||||||
|
`server/swagger/swagger-output.json` is committed so the docs work with no build step. After adding
|
||||||
|
or changing a route, regenerate it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm run swagger # → server/swagger/swagger-output.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||||
|
not crash).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
45
bot/.env.example
Normal file
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 { SiteProvider } from './contexts/SiteContext.jsx'
|
||||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||||
import RequireAuth from './components/RequireAuth.jsx'
|
import RequireAuth from './components/RequireAuth.jsx'
|
||||||
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
import Portal from './routes/public/Portal.jsx'
|
||||||
@@ -27,9 +28,12 @@ import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
|||||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import 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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -72,8 +76,20 @@ export default function App() {
|
|||||||
<Route path="wiki" element={<WikiAdmin />} />
|
<Route path="wiki" element={<WikiAdmin />} />
|
||||||
<Route path="hero" element={<HeroEditor />} />
|
<Route path="hero" element={<HeroEditor />} />
|
||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
|
<Route
|
||||||
|
path="moderation"
|
||||||
|
element={
|
||||||
|
<RoleGate roles={['admin', 'moderator']}>
|
||||||
|
<Outlet />
|
||||||
|
</RoleGate>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Moderation />} />
|
||||||
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||||
|
</Route>
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export const api = {
|
|||||||
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
loginTotp: (challenge, code) =>
|
loginTotp: (challenge, code) =>
|
||||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||||
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||||
|
// the callback, so only the code is sent). Returns { user, returnTo }.
|
||||||
|
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||||
authProviders: () => req('/auth/providers'),
|
authProviders: () => req('/auth/providers'),
|
||||||
@@ -117,6 +120,52 @@ export const api = {
|
|||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
|
modRecent: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||||
|
modMembers: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modFilterHits: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modSpamHits: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||||
|
modUserActions: (discordId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||||
|
addModNote: (discordId, data) =>
|
||||||
|
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
||||||
|
|
||||||
// ----- account security (self-service 2FA) -----
|
// ----- account security (self-service 2FA) -----
|
||||||
getAccount: () => req('/admin/account'),
|
getAccount: () => req('/admin/account'),
|
||||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||||
@@ -132,6 +181,10 @@ export const api = {
|
|||||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||||
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
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 }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@ export function AuthProvider({ children }) {
|
|||||||
return data.user
|
return data.user
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
|
||||||
|
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
|
||||||
|
const ssoLoginTotp = useCallback(async (code) => {
|
||||||
|
const data = await api.ssoLoginTotp(code)
|
||||||
|
setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
@@ -46,7 +54,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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,19 @@ import MoonDot from '../../components/MoonDot.jsx'
|
|||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// `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 = [
|
const NAV = [
|
||||||
{ to: '/admin', label: 'Dashboard', end: true },
|
{ to: '/admin', label: 'Dashboard', end: true },
|
||||||
{ to: '/admin/posts', label: 'Posts' },
|
{ to: '/admin/posts', label: 'Posts' },
|
||||||
{ to: '/admin/wiki', label: 'Wiki' },
|
{ to: '/admin/wiki', label: 'Wiki' },
|
||||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||||
|
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/settings', label: 'Settings' },
|
{ to: '/admin/settings', label: 'Settings' },
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
{ to: '/admin/activity', label: 'Activity' },
|
||||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||||
|
{ to: '/admin/discord-bot', label: 'Discord Bot' },
|
||||||
{ to: '/admin/auth-providers', label: 'Authentication' },
|
{ to: '/admin/auth-providers', label: 'Authentication' },
|
||||||
{ to: '/admin/users', label: 'Users' },
|
{ to: '/admin/users', label: 'Users' },
|
||||||
{ to: '/admin/account', label: 'Account' },
|
{ to: '/admin/account', label: 'Account' },
|
||||||
@@ -22,9 +27,11 @@ const TITLES = {
|
|||||||
'/admin/posts': 'Posts',
|
'/admin/posts': 'Posts',
|
||||||
'/admin/wiki': 'Wiki Pages',
|
'/admin/wiki': 'Wiki Pages',
|
||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Bot Activity',
|
'/admin/bot-activity': 'Bot Activity',
|
||||||
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
'/admin/account': 'Account Security',
|
'/admin/account': 'Account Security',
|
||||||
@@ -46,11 +53,31 @@ export default function AdminLayout() {
|
|||||||
const { mode } = useSite()
|
const { mode } = useSite()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = TITLES[location.pathname] || 'Admin'
|
const title =
|
||||||
|
TITLES[location.pathname] ||
|
||||||
|
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
||||||
// The hero canvas editor needs room — let it use the full content width.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|
||||||
|
// Moderators only get the moderation section + their own account security.
|
||||||
|
const isModerator = user?.role === 'moderator'
|
||||||
|
const 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).
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const meta = document.createElement('meta')
|
const meta = document.createElement('meta')
|
||||||
@@ -92,7 +119,7 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{NAV.map((n) => (
|
{navItems.map((n) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const honeypotStyle = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const { user, login, loginTotp } = useAuth()
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/admin'
|
const dest = location.state?.from?.pathname || '/admin'
|
||||||
@@ -42,10 +42,12 @@ export default function AdminLogin() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
// Two-factor step state.
|
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
|
||||||
|
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
|
||||||
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||||
const [challenge, setChallenge] = useState('')
|
const [challenge, setChallenge] = useState('')
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
|
const [ssoTotp, setSsoTotp] = useState(false)
|
||||||
|
|
||||||
// SSO providers to offer (empty if none configured) + any error the callback
|
// SSO providers to offer (empty if none configured) + any error the callback
|
||||||
// bounced us back with (?sso_error=...).
|
// bounced us back with (?sso_error=...).
|
||||||
@@ -57,6 +59,16 @@ export default function AdminLogin() {
|
|||||||
if (user) navigate(dest, { replace: true })
|
if (user) navigate(dest, { replace: true })
|
||||||
}, [user, dest, navigate])
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
||||||
|
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
|
||||||
|
// before it will issue a session. Jump straight to the code step.
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(location.search).get('sso_totp')) {
|
||||||
|
setStage('totp')
|
||||||
|
setSsoTotp(true)
|
||||||
|
}
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
||||||
// still works with password login and simply shows no provider buttons.
|
// still works with password login and simply shows no provider buttons.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,16 +113,25 @@ export default function AdminLogin() {
|
|||||||
setError('')
|
setError('')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await loginTotp(challenge, code)
|
if (ssoTotp) {
|
||||||
navigate(dest, { replace: true })
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
|
navigate(returnTo || '/admin', { replace: true })
|
||||||
|
} else {
|
||||||
|
await loginTotp(challenge, code)
|
||||||
|
navigate(dest, { replace: true })
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
setError(
|
setError(
|
||||||
err.status === 401 && /expired/i.test(err.message)
|
expired
|
||||||
? 'Your verification session expired. Please sign in again.'
|
? 'Your verification session expired. Please sign in again.'
|
||||||
: 'Invalid verification code.',
|
: 'Invalid verification code.',
|
||||||
)
|
)
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
if (err.status === 401 && /expired/i.test(err.message)) setStage('creds')
|
if (expired) {
|
||||||
|
setStage('creds')
|
||||||
|
setSsoTotp(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,6 +39,20 @@ function newElement(type, z) {
|
|||||||
|
|
||||||
const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
|
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() {
|
export default function HeroEditor() {
|
||||||
const [layout, setLayout] = useState(null)
|
const [layout, setLayout] = useState(null)
|
||||||
const [live, setLive] = useState(null)
|
const [live, setLive] = useState(null)
|
||||||
@@ -190,6 +204,10 @@ export default function HeroEditor() {
|
|||||||
const rect = canvasRef.current.getBoundingClientRect()
|
const rect = canvasRef.current.getBoundingClientRect()
|
||||||
const sx = e.clientX
|
const sx = e.clientX
|
||||||
const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64)
|
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
|
const node = e.currentTarget
|
||||||
try {
|
try {
|
||||||
node.setPointerCapture(e.pointerId)
|
node.setPointerCapture(e.pointerId)
|
||||||
@@ -199,11 +217,17 @@ export default function HeroEditor() {
|
|||||||
const move = (ev) => {
|
const move = (ev) => {
|
||||||
const dxPx = ev.clientX - sx
|
const dxPx = ev.clientX - sx
|
||||||
const dxLogical = dxPx / scale // client px → stage px
|
const dxLogical = dxPx / scale // client px → stage px
|
||||||
let val
|
if (el.type === 'image') {
|
||||||
if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // %
|
updateProps(el.id, { width: Math.round(clamp(orig + (dxPx / rect.width) * 100, 5, 100)) }) // %
|
||||||
else if (el.type === 'moon') val = clamp(orig + dxLogical, 24, 400) // px
|
} else if (el.type === 'moon') {
|
||||||
else val = clamp(orig + dxLogical, 120, 1180) // text_block box px
|
updateProps(el.id, { size: Math.round(clamp(orig + dxLogical, 24, 400)) }) // px
|
||||||
updateProps(el.id, { [dim]: Math.round(val) })
|
} 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 = () => {
|
const up = () => {
|
||||||
node.removeEventListener('pointermove', move)
|
node.removeEventListener('pointermove', move)
|
||||||
@@ -324,7 +348,7 @@ export default function HeroEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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">
|
<select value={form.role} onChange={set('role')} className="select">
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
<option value="editor">editor</option>
|
<option value="editor">editor</option>
|
||||||
|
<option value="moderator">moderator</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js'
|
|||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import UserEditor from './UserEditor.jsx'
|
import UserEditor from './UserEditor.jsx'
|
||||||
|
|
||||||
|
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
||||||
|
|
||||||
export default function UsersAdmin() {
|
export default function UsersAdmin() {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
@@ -16,7 +18,7 @@ export default function UsersAdmin() {
|
|||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
<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' }}>
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
Manage admin and editor accounts
|
Manage admin, editor, and moderator accounts
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||||
+ Add user
|
+ Add user
|
||||||
@@ -44,7 +46,7 @@ export default function UsersAdmin() {
|
|||||||
{u.username}
|
{u.username}
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-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>
|
||||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
|||||||
@@ -1,34 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import HeroElement from '../../components/HeroElement.jsx'
|
import HeroElement from '../../components/HeroElement.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.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.
|
// 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'
|
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
|
||||||
|
|
||||||
@@ -77,8 +53,8 @@ export default function Portal() {
|
|||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
minHeight: 'clamp(600px,72vh,860px)',
|
flex: 1,
|
||||||
overflow: 'hidden',
|
minHeight: '100vh',
|
||||||
...bgStyle,
|
...bgStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -88,35 +64,6 @@ export default function Portal() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -613,6 +613,29 @@ button[disabled] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid var(--line);
|
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 {
|
.link-accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -35,10 +35,46 @@ services:
|
|||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
# Bind-mount logs to the host so app.log is directly readable at ./logs/
|
# Bind-mount logs to the host so app.log is directly readable at ./logs/
|
||||||
- ./logs:/app/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.
|
# Binds 0.0.0.0 (no 127.0.0.1 prefix) so Pangolin can reach the container.
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "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:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
uploads:
|
uploads:
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"install-server": "npm install --prefix server",
|
"install-server": "npm install --prefix server",
|
||||||
"install-client": "npm install --prefix client",
|
"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",
|
"server": "npm run dev --prefix server",
|
||||||
"client": "npm run dev --prefix client",
|
"client": "npm run dev --prefix client",
|
||||||
|
"bot": "npm run dev --prefix bot",
|
||||||
"seed": "npm run seed --prefix server",
|
"seed": "npm run seed --prefix server",
|
||||||
"build": "npm run build --prefix client",
|
"build": "npm run build --prefix client",
|
||||||
"start": "npm start --prefix server"
|
"start": "npm start --prefix server"
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
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).
|
# Logging — written to BOTH the console and a log file (default <server>/logs/app.log).
|
||||||
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
|
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
|
||||||
FILE_LOG_LEVEL=debug # file verbosity
|
FILE_LOG_LEVEL=debug # file verbosity
|
||||||
@@ -73,3 +77,15 @@ SMTP_PASS=
|
|||||||
CONTACT_TO=UOMysticmoon@gmail.com
|
CONTACT_TO=UOMysticmoon@gmail.com
|
||||||
|
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
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,9 +6,12 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
username VARCHAR(32) NOT NULL UNIQUE,
|
username VARCHAR(32) NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR(72) NOT NULL,
|
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_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
||||||
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
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,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_login_at DATETIME NULL
|
last_login_at DATETIME NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -176,6 +179,272 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
INDEX idx_mrt_expires (expires_at)
|
INDEX idx_mrt_expires (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
-- 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
|
-- 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.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
@@ -184,6 +453,12 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
-- Opt-in TOTP two-factor columns for databases created before login hardening.
|
-- 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_secret VARCHAR(64) NULL;
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
|
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 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 category_id INT NULL;
|
||||||
|
|||||||
182
server/package-lock.json
generated
182
server/package-lock.json
generated
@@ -25,12 +25,21 @@
|
|||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sanitize-html": "^2.17.5",
|
"sanitize-html": "^2.17.5",
|
||||||
"speakeasy": "^2.0.0"
|
"speakeasy": "^2.0.0",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
@@ -59,6 +68,19 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -336,6 +358,13 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
@@ -845,6 +874,13 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -915,6 +951,28 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
@@ -928,6 +986,37 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -1041,6 +1130,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1129,6 +1230,19 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
@@ -1560,6 +1674,16 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
@@ -1620,6 +1744,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
@@ -2082,6 +2216,43 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -2208,6 +2379,13 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "nodemon src/server.js",
|
"dev": "nodemon src/server.js",
|
||||||
"seed": "node db/seed.js",
|
"seed": "node db/seed.js",
|
||||||
|
"swagger": "node swagger/swagger.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -34,9 +35,11 @@
|
|||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sanitize-html": "^2.17.5",
|
"sanitize-html": "^2.17.5",
|
||||||
"speakeasy": "^2.0.0"
|
"speakeasy": "^2.0.0",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.4"
|
"nodemon": "^3.1.4",
|
||||||
|
"swagger-autogen": "^2.23.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const morgan = require('morgan')
|
|||||||
const cookieParser = require('cookie-parser')
|
const cookieParser = require('cookie-parser')
|
||||||
require('dotenv').config()
|
require('dotenv').config()
|
||||||
|
|
||||||
|
const swaggerUi = require('swagger-ui-express')
|
||||||
|
|
||||||
const apiRouter = require('./router/api.router')
|
const apiRouter = require('./router/api.router')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||||
@@ -75,8 +77,35 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
||||||
|
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
|
||||||
|
// is generated from route annotations by `npm run swagger` (server/swagger/).
|
||||||
|
// Loaded lazily and guarded so a missing spec never crashes the server.
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const swaggerSpec = require('../swagger/swagger-output.json')
|
||||||
|
app.get('/api/docs.json', (req, res) => {
|
||||||
|
// #swagger.ignore = true
|
||||||
|
res.json(swaggerSpec)
|
||||||
|
})
|
||||||
|
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||||
|
customSiteTitle: 'UOMysticmoon API docs',
|
||||||
|
swaggerOptions: { persistAuthorization: true },
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
|
||||||
|
message: err.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── API ───────────────────────────────────────────────────────────────
|
// ── 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', apiRouter)
|
||||||
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ const sessionService = require('./session.service')
|
|||||||
const users = require('../model/users/users.model')
|
const users = require('../model/users/users.model')
|
||||||
const log = require('../utils/logger')('session')
|
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
|
// 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 —
|
// session (no DB hit), its auth method, and request metadata. Never rejects —
|
||||||
// anonymous requests simply pass through with req.session undefined.
|
// anonymous requests simply pass through with req.session undefined.
|
||||||
@@ -38,6 +50,18 @@ async function requireAuth(req, res, next) {
|
|||||||
try {
|
try {
|
||||||
const user = await users.getById(session.userId)
|
const user = await users.getById(session.userId)
|
||||||
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
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.user = user
|
||||||
req.session = session
|
req.session = session
|
||||||
req.authMethod = session.authMethod
|
req.authMethod = session.authMethod
|
||||||
|
|||||||
@@ -14,16 +14,20 @@
|
|||||||
// role,
|
// role,
|
||||||
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
|
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
|
||||||
// createdAt, // ms epoch the token was issued (JWT iat)
|
// 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
|
// lastSeenAt, // ms epoch this session was last validated
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
|
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
|
||||||
// server-side session store yet — these are documented hook points for a future
|
// denylist (revoked_sessions — single logout) and a per-user cutoff
|
||||||
// store (e.g. a denylist of jti, or mobile refresh-token records).
|
// (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 crypto = require('crypto')
|
||||||
|
|
||||||
const token = require('./token')
|
const token = require('./token')
|
||||||
|
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
|
||||||
|
const users = require('../model/users/users.model')
|
||||||
const log = require('../utils/logger')('session')
|
const log = require('../utils/logger')('session')
|
||||||
|
|
||||||
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
|
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
|
||||||
@@ -32,10 +36,24 @@ const log = require('../utils/logger')('session')
|
|||||||
// authenticated without changing this module per provider.
|
// authenticated without changing this module per provider.
|
||||||
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
|
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
|
// Build a Session object from a decoded JWT payload. Returns null for anything
|
||||||
// that is not a full session (e.g. a stage-tagged TOTP challenge token).
|
// that is not a full session. Validation is positively typed: a token qualifies
|
||||||
|
// only if it was explicitly minted as a session. As belt-and-suspenders we also
|
||||||
|
// reject any token carrying a non-session marker (stage = TOTP challenge, kind =
|
||||||
|
// SSO transaction), so a future minting path that forgets to omit those still
|
||||||
|
// can't produce an accepted session.
|
||||||
function sessionFromDecoded(decoded, now = Date.now()) {
|
function sessionFromDecoded(decoded, now = Date.now()) {
|
||||||
if (!decoded || decoded.stage) return null
|
if (!decoded || decoded.typ !== SESSION_TYP) return null
|
||||||
|
if (decoded.stage || decoded.kind) return null
|
||||||
return {
|
return {
|
||||||
sessionId: decoded.jti || null,
|
sessionId: decoded.jti || null,
|
||||||
userId: decoded.id,
|
userId: decoded.id,
|
||||||
@@ -43,6 +61,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
|
|||||||
role: decoded.role,
|
role: decoded.role,
|
||||||
authMethod: decoded.authMethod || 'local',
|
authMethod: decoded.authMethod || 'local',
|
||||||
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
||||||
|
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
|
||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +75,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
|
|||||||
function createSession(user, authMethod = 'local') {
|
function createSession(user, authMethod = 'local') {
|
||||||
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
|
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
|
||||||
const sessionId = crypto.randomUUID()
|
const sessionId = crypto.randomUUID()
|
||||||
const raw = token.signToken(user, { authMethod: method, jti: sessionId })
|
const raw = token.signToken(user, { authMethod: method, jti: sessionId, typ: SESSION_TYP })
|
||||||
const session = sessionFromDecoded(token.verifyToken(raw))
|
const session = sessionFromDecoded(token.verifyToken(raw))
|
||||||
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
|
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
|
||||||
return { token: raw, session }
|
return { token: raw, session }
|
||||||
@@ -124,7 +143,7 @@ function mintMobileTokens(user, meta = {}, now = Date.now()) {
|
|||||||
const sessionId = crypto.randomUUID()
|
const sessionId = crypto.randomUUID()
|
||||||
const accessToken = token.signToken(
|
const accessToken = token.signToken(
|
||||||
user,
|
user,
|
||||||
{ authMethod: 'mobile', jti: sessionId },
|
{ authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
|
||||||
{ expiresIn: MOBILE_ACCESS_TTL },
|
{ expiresIn: MOBILE_ACCESS_TTL },
|
||||||
)
|
)
|
||||||
// 256 bits of entropy, url-safe. Opaque — carries no claims.
|
// 256 bits of entropy, url-safe. Opaque — carries no claims.
|
||||||
@@ -179,23 +198,52 @@ function sessionMeta(req) {
|
|||||||
return { ip, userAgent, deviceHash }
|
return { ip, userAgent, deviceHash }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Revocation / invalidation (stubs) ──────────────────────────────────────
|
// ── Revocation / invalidation ──────────────────────────────────────────────
|
||||||
// JWTs are stateless: there is no store to revoke against yet. These are the
|
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
|
||||||
// hook points a future session store (jti denylist, mobile refresh records)
|
// two server-side stores these functions write:
|
||||||
// will implement. They log and report success so callers can wire them in now.
|
// • revoked_sessions — a per-session jti denylist (single logout)
|
||||||
|
// • users.tokens_valid_after — a per-user cutoff (log out everywhere)
|
||||||
|
// A jti + its expiry (from the decoded token) are needed to denylist one session;
|
||||||
|
// invalidating all of a user's sessions only needs their id.
|
||||||
|
|
||||||
function revokeSession(sessionId) {
|
// Revoke a single session by its jti. Needs the token's expiry so the denylist
|
||||||
log.info('revokeSession (stub — no session store yet)', { sessionId })
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateSession(sessionId) {
|
// Alias kept for callers that speak of "invalidating" one session.
|
||||||
log.info('invalidateSession (stub — no session store yet)', { sessionId })
|
async function invalidateSession(sessionId, opts) {
|
||||||
return true
|
return revokeSession(sessionId, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateAllUserSessions(userId) {
|
// Has this session (jti) been individually revoked? Used by requireAuth on every
|
||||||
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +257,7 @@ module.exports = {
|
|||||||
sessionMeta,
|
sessionMeta,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
invalidateSession,
|
invalidateSession,
|
||||||
|
isSessionRevoked,
|
||||||
invalidateAllUserSessions,
|
invalidateAllUserSessions,
|
||||||
// Mobile bearer sessions.
|
// Mobile bearer sessions.
|
||||||
createMobileSession,
|
createMobileSession,
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ const token = require('./token')
|
|||||||
const TX_COOKIE = 'sso_tx'
|
const TX_COOKIE = 'sso_tx'
|
||||||
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
|
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
|
||||||
|
|
||||||
|
// Second leg of an SSO login for an account that has TOTP enabled. The callback
|
||||||
|
// authenticated the user with the IdP but must NOT bypass their second factor
|
||||||
|
// (see issue #31), so instead of minting a session it stages this signed,
|
||||||
|
// httpOnly cookie and routes the browser through the TOTP form — mirroring the
|
||||||
|
// local password→TOTP gate. TTL matches the local challenge window.
|
||||||
|
const TOTP_COOKIE = 'sso_totp'
|
||||||
|
const TOTP_TTL = '5m'
|
||||||
|
|
||||||
// base64url of random bytes — used for the nonce and the PKCE verifier.
|
// base64url of random bytes — used for the nonce and the PKCE verifier.
|
||||||
function randomUrlSafe(bytes = 32) {
|
function randomUrlSafe(bytes = 32) {
|
||||||
return crypto.randomBytes(bytes).toString('base64url')
|
return crypto.randomBytes(bytes).toString('base64url')
|
||||||
@@ -56,4 +64,38 @@ function verifyTx(txToken, stateNonce) {
|
|||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TX_COOKIE, TX_TTL, createTx, verifyTx, codeChallengeFor, randomUrlSafe }
|
// Stage the pending second factor for an SSO login. Carries the context the
|
||||||
|
// callback already resolved (userId, provider, authMethod, returnTo) so that
|
||||||
|
// presenting a valid code alone finishes the login. It is deliberately NOT a
|
||||||
|
// session: `stage: 'totp'` makes session validation reject it (same marker the
|
||||||
|
// local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and
|
||||||
|
// scopes it to the SSO completion endpoint.
|
||||||
|
function createTotpPending({ userId, provider, authMethod, returnTo }) {
|
||||||
|
return token.signToken(
|
||||||
|
{ id: userId }, // subject only; identity is re-loaded fresh when the code is verified
|
||||||
|
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo },
|
||||||
|
{ expiresIn: TOTP_TTL },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify a pending-TOTP cookie. Returns the payload
|
||||||
|
// ({ id, provider, authMethod, returnTo, ... }) or null if missing/expired/wrong-kind.
|
||||||
|
function verifyTotpPending(pendingToken) {
|
||||||
|
if (!pendingToken) return null
|
||||||
|
const decoded = token.verifyToken(pendingToken)
|
||||||
|
if (!decoded || decoded.stage !== 'totp' || decoded.kind !== 'sso_totp') return null
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TX_COOKIE,
|
||||||
|
TX_TTL,
|
||||||
|
TOTP_COOKIE,
|
||||||
|
TOTP_TTL,
|
||||||
|
createTx,
|
||||||
|
verifyTx,
|
||||||
|
createTotpPending,
|
||||||
|
verifyTotpPending,
|
||||||
|
codeChallengeFor,
|
||||||
|
randomUrlSafe,
|
||||||
|
}
|
||||||
|
|||||||
26
server/src/internalApp.js
Normal file
26
server/src/internalApp.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Standalone Express app for server<->bot internal traffic. It is mounted on its
|
||||||
|
// OWN http listener (INTERNAL_PORT, default 3001) in server.js — an unpublished,
|
||||||
|
// compose-network-only port, mirroring how the bot exposes its internal API on
|
||||||
|
// 4100. Crucially it is NOT part of the public API app (app.js), so /internal/*
|
||||||
|
// (which returns the DECRYPTED Discord bot token) can never ride the same
|
||||||
|
// listener Pangolin proxies to the world. Shared-secret gated by
|
||||||
|
// requireInternalKey inside internal.routes. See issue #33.
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const internalRouter = require('./router/v1/internal/internal.routes')
|
||||||
|
|
||||||
|
const internalApp = express()
|
||||||
|
|
||||||
|
internalApp.use(express.json())
|
||||||
|
|
||||||
|
// Liveness probe for this listener (no secret required); mirrors the bot's
|
||||||
|
// /health on 4100. Useful for compose healthchecks without exposing anything.
|
||||||
|
internalApp.get('/health', (req, res) => res.json({ status: 'ok' }))
|
||||||
|
|
||||||
|
// requireInternalKey is applied inside internal.routes.
|
||||||
|
internalApp.use('/internal', internalRouter)
|
||||||
|
|
||||||
|
// Anything else on this listener is not a real internal route.
|
||||||
|
internalApp.use((req, res) => res.status(404).json({ message: 'Not found' }))
|
||||||
|
|
||||||
|
module.exports = internalApp
|
||||||
20
server/src/middleware/requireInternalKey.js
Normal file
20
server/src/middleware/requireInternalKey.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Gate for server-side /internal/* routes. The only caller is the bot process,
|
||||||
|
// on its own boot, over the private compose network — never expose this route
|
||||||
|
// through the public reverse proxy. Timing-safe compare so response time can't
|
||||||
|
// be used to brute-force the shared secret one byte at a time. Same pattern as
|
||||||
|
// bot/src/internal/requireInternalKey.js on the other side of this call.
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
function requireInternalKey(req, res, next) {
|
||||||
|
const expected = process.env.BOT_INTERNAL_KEY || ''
|
||||||
|
const provided = req.get('X-Internal-Key') || ''
|
||||||
|
|
||||||
|
const a = Buffer.from(expected)
|
||||||
|
const b = Buffer.from(provided)
|
||||||
|
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||||
|
|
||||||
|
if (!match) return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = requireInternalKey
|
||||||
28
server/src/model/botConfig/botConfig.db.js
Normal file
28
server/src/model/botConfig/botConfig.db.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const COLS =
|
||||||
|
'id, guild_id, bot_token_enc, application_id, enabled, status, status_detail, last_connected_at, updated_by, created_at, updated_at'
|
||||||
|
|
||||||
|
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
|
||||||
|
async function get() {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM bot_config WHERE id = 1 LIMIT 1`)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the singleton row. `fields` are column values already prepared by the
|
||||||
|
// model (token pre-encrypted). Only the provided columns are written/updated.
|
||||||
|
async function upsert(fields) {
|
||||||
|
const cols = Object.keys(fields)
|
||||||
|
const vals = cols.map((c) => fields[c])
|
||||||
|
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
|
||||||
|
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
|
||||||
|
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||||
|
await query(
|
||||||
|
`INSERT INTO bot_config (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
return get()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { get, upsert }
|
||||||
73
server/src/model/botConfig/botConfig.model.js
Normal file
73
server/src/model/botConfig/botConfig.model.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Discord bot config store (Phase 1). Mirrors the authProviders model split:
|
||||||
|
// the DB layer only ever sees ciphertext, and only getWithToken() (used
|
||||||
|
// internally to push config to the bot process / to the bot-config internal
|
||||||
|
// endpoint) decrypts it. The admin-facing getSafe() never includes the token.
|
||||||
|
|
||||||
|
const db = require('./botConfig.db')
|
||||||
|
const secretBox = require('../../utils/secretBox')
|
||||||
|
|
||||||
|
function toSafe(row) {
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
guildId: null,
|
||||||
|
applicationId: null,
|
||||||
|
enabled: false,
|
||||||
|
hasToken: false,
|
||||||
|
status: 'disconnected',
|
||||||
|
statusDetail: null,
|
||||||
|
lastConnectedAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
guildId: row.guild_id || null,
|
||||||
|
applicationId: row.application_id || null,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
hasToken: Boolean(row.bot_token_enc),
|
||||||
|
status: row.status || 'disconnected',
|
||||||
|
statusDetail: row.status_detail || null,
|
||||||
|
lastConnectedAt: row.last_connected_at || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSafe() {
|
||||||
|
return toSafe(await db.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypted token included — server-side only (pushing config to the bot, or
|
||||||
|
// serving the shared-secret-gated /internal/bot-config route).
|
||||||
|
async function getWithToken() {
|
||||||
|
const row = await db.get()
|
||||||
|
if (!row) return null
|
||||||
|
return { ...toSafe(row), token: row.bot_token_enc ? secretBox.decrypt(row.bot_token_enc) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save admin-supplied config. `token` undefined or '' means "leave the
|
||||||
|
// existing token unchanged" (same convention as authProviders.save).
|
||||||
|
async function save({ guildId, applicationId, token, enabled, updatedBy }) {
|
||||||
|
const fields = {}
|
||||||
|
if (guildId !== undefined) fields.guild_id = guildId
|
||||||
|
if (applicationId !== undefined) fields.application_id = applicationId
|
||||||
|
if (token) fields.bot_token_enc = secretBox.encrypt(token)
|
||||||
|
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||||
|
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the bot's last-reported status into the DB so the admin panel has
|
||||||
|
// something to show even if the bot is briefly unreachable.
|
||||||
|
async function recordStatus({ status, statusDetail, lastConnectedAt }) {
|
||||||
|
const fields = {}
|
||||||
|
if (status !== undefined) fields.status = status
|
||||||
|
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||||
|
// lastConnectedAt arrives over HTTP as a JSON-serialized ISO string (e.g.
|
||||||
|
// "2026-07-04T18:49:51.429Z") — MariaDB's DATETIME parser rejects the "T"/
|
||||||
|
// "Z"/milliseconds in that format. Convert to a real Date so the mariadb
|
||||||
|
// driver formats it correctly on the wire.
|
||||||
|
if (lastConnectedAt !== undefined) fields.last_connected_at = lastConnectedAt ? new Date(lastConnectedAt) : null
|
||||||
|
if (Object.keys(fields).length === 0) return getSafe()
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||||
51
server/src/model/modNotes/modNotes.db.js
Normal file
51
server/src/model/modNotes/modNotes.db.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Staff notes on a Discord user (server-owned, see db/schema.sql mod_notes).
|
||||||
|
// Notes are never user-visible; admin_only notes are filtered out for non-admin
|
||||||
|
// callers at this layer via includeAdminOnly.
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
const visClause = includeAdminOnly ? '' : "AND n.visibility = 'staff_only'"
|
||||||
|
return query(
|
||||||
|
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
|
||||||
|
n.body, n.visibility, n.created_at,
|
||||||
|
u.username AS author_username
|
||||||
|
FROM mod_notes n
|
||||||
|
LEFT JOIN users u ON u.id = n.author_user_id
|
||||||
|
WHERE n.discord_user_id = ? ${visClause}
|
||||||
|
ORDER BY n.id DESC`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insert({ discordUserId, authorUserId = null, authorTag = null, body, visibility = 'staff_only' }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO mod_notes (discord_user_id, author_user_id, author_tag, body, visibility)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[discordUserId, authorUserId, authorTag, body, visibility],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
|
||||||
|
n.body, n.visibility, n.created_at,
|
||||||
|
u.username AS author_username
|
||||||
|
FROM mod_notes n
|
||||||
|
LEFT JOIN users u ON u.id = n.author_user_id
|
||||||
|
WHERE n.id = ? LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
const visClause = includeAdminOnly ? '' : "AND visibility = 'staff_only'"
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT COUNT(*) AS c FROM mod_notes WHERE discord_user_id = ? ${visClause}`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return Number(rows[0].c)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listForUser, insert, getById, countForUser }
|
||||||
18
server/src/model/modNotes/modNotes.model.js
Normal file
18
server/src/model/modNotes/modNotes.model.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const modNotesDb = require('./modNotes.db')
|
||||||
|
|
||||||
|
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
return modNotesDb.listForUser(discordId, { includeAdminOnly })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add({ discordUserId, author, body, visibility = 'staff_only' }) {
|
||||||
|
const id = await modNotesDb.insert({
|
||||||
|
discordUserId,
|
||||||
|
authorUserId: author ? author.id : null,
|
||||||
|
authorTag: author ? author.username : null,
|
||||||
|
body,
|
||||||
|
visibility,
|
||||||
|
})
|
||||||
|
return modNotesDb.getById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listForUser, add }
|
||||||
188
server/src/model/moderation/moderation.db.js
Normal file
188
server/src/model/moderation/moderation.db.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// Read-only access to the bot-owned moderation tables (mod_actions) for the
|
||||||
|
// admin moderation dashboard (Phase 6). These tables are normally owned by the
|
||||||
|
// bot process (bot/src/db.js) — see the comment in db/schema.sql — but they live
|
||||||
|
// in the same physical database, so the site reads them directly through the
|
||||||
|
// shared pool rather than round-tripping the bot over the internal API. This
|
||||||
|
// module NEVER writes them; all writes still belong to the bot.
|
||||||
|
//
|
||||||
|
// mod_actions is the single source of truth for ban/kick/mute/warn (every warn
|
||||||
|
// command also mirrors into `warnings`, so counting mod_actions avoids double
|
||||||
|
// counting). Accounts are correlated to Discord ids via user_identities
|
||||||
|
// (provider='discord', subject=<snowflake>), the same link the SSO flow writes.
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const TYPES = ['ban', 'kick', 'mute', 'warn']
|
||||||
|
|
||||||
|
// Per-type counts across three nested windows in a single scan. Boolean
|
||||||
|
// comparisons yield 1/0 in MariaDB, so SUM(created_at >= cutoff) counts the
|
||||||
|
// rows inside each window. Returns raw rows: [{ action_type, d1, d7, d30 }].
|
||||||
|
async function countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
return query(
|
||||||
|
`SELECT action_type,
|
||||||
|
SUM(created_at >= ?) AS d1,
|
||||||
|
SUM(created_at >= ?) AS d7,
|
||||||
|
SUM(created_at >= ?) AS d30
|
||||||
|
FROM mod_actions
|
||||||
|
WHERE created_at >= ?
|
||||||
|
GROUP BY action_type`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_SELECT = `
|
||||||
|
SELECT ma.id, ma.guild_id, ma.action_type,
|
||||||
|
ma.target_user_id, ma.target_tag,
|
||||||
|
ma.staff_user_id, ma.staff_tag,
|
||||||
|
ma.reason, ma.duration_seconds, ma.created_at,
|
||||||
|
ui.user_id AS target_site_user_id,
|
||||||
|
u.username AS target_site_username
|
||||||
|
FROM mod_actions ma
|
||||||
|
LEFT JOIN user_identities ui
|
||||||
|
ON ui.provider = 'discord' AND ui.subject = ma.target_user_id
|
||||||
|
LEFT JOIN users u ON u.id = ui.user_id`
|
||||||
|
|
||||||
|
// Most-recent-first action feed, optionally filtered by type. limit/offset
|
||||||
|
// pagination matching the activity-log convention.
|
||||||
|
async function recentActions({ type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type ? 'WHERE ma.action_type = ?' : ''
|
||||||
|
const params = type ? [type, limit, offset] : [limit, offset]
|
||||||
|
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full action history for one Discord user, optionally filtered by type.
|
||||||
|
async function userActions(discordId, { type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type
|
||||||
|
? 'WHERE ma.target_user_id = ? AND ma.action_type = ?'
|
||||||
|
: 'WHERE ma.target_user_id = ?'
|
||||||
|
const params = type ? [discordId, type, limit, offset] : [discordId, limit, offset]
|
||||||
|
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-time per-type counts for one user.
|
||||||
|
async function userCounts(discordId) {
|
||||||
|
return query(
|
||||||
|
`SELECT action_type, COUNT(*) AS c FROM mod_actions
|
||||||
|
WHERE target_user_id = ? GROUP BY action_type`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest username snapshot the bot recorded for this Discord id (usernames drift).
|
||||||
|
async function latestTag(discordId) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT target_tag FROM mod_actions WHERE target_user_id = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return rows[0] ? rows[0].target_tag : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked site account for a Discord id, if any (via user_identities).
|
||||||
|
async function linkedAccount(discordId) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT u.id, u.username, u.role
|
||||||
|
FROM user_identities ui
|
||||||
|
JOIN users u ON u.id = ui.user_id
|
||||||
|
WHERE ui.provider = 'discord' AND ui.subject = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 6b: member events + filter/spam hits (bot-owned, read-only) ──
|
||||||
|
|
||||||
|
// Join/leave counts per window (grouped by event_type).
|
||||||
|
async function memberCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
return query(
|
||||||
|
`SELECT event_type,
|
||||||
|
SUM(created_at >= ?) AS d1,
|
||||||
|
SUM(created_at >= ?) AS d7,
|
||||||
|
SUM(created_at >= ?) AS d30
|
||||||
|
FROM member_events
|
||||||
|
WHERE created_at >= ?
|
||||||
|
GROUP BY event_type`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributed-invite join counts per window (joins whose invite we identified).
|
||||||
|
async function inviteJoinCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
|
||||||
|
FROM member_events
|
||||||
|
WHERE event_type = 'join' AND invite_code IS NOT NULL AND created_at >= ?`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row-count per window for a simple event table. `table` is an internal literal
|
||||||
|
// ('filter_hits' | 'spam_hits'), never user input — see the caller allowlist.
|
||||||
|
async function tableCountsByWindow(table, { cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
|
||||||
|
FROM ${table} WHERE created_at >= ?`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentMemberEvents({ type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type ? 'WHERE event_type = ?' : ''
|
||||||
|
const params = type ? [type, limit, offset] : [limit, offset]
|
||||||
|
return query(
|
||||||
|
`SELECT id, guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag, created_at
|
||||||
|
FROM member_events ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentFilterHits({ limit = 50, offset = 0 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken, created_at
|
||||||
|
FROM filter_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentSpamHits({ limit = 50, offset = 0 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, guild_id, spam_type, discord_user_id, username, channel_id, created_at
|
||||||
|
FROM spam_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-lookup: match a Discord id exactly, or a username snapshot (target_tag)
|
||||||
|
// by prefix, returning the most recently seen distinct targets. Powers the
|
||||||
|
// dashboard search box (usernames drift, so we search historical snapshots too).
|
||||||
|
async function searchTargets(term, { limit = 20 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT ma.target_user_id, MAX(ma.target_tag) AS target_tag,
|
||||||
|
COUNT(*) AS action_count, MAX(ma.created_at) AS last_seen
|
||||||
|
FROM mod_actions ma
|
||||||
|
WHERE ma.target_user_id = ? OR ma.target_tag LIKE ?
|
||||||
|
GROUP BY ma.target_user_id
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[term, `${term}%`, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TYPES,
|
||||||
|
countsByWindow,
|
||||||
|
recentActions,
|
||||||
|
userActions,
|
||||||
|
userCounts,
|
||||||
|
latestTag,
|
||||||
|
linkedAccount,
|
||||||
|
searchTargets,
|
||||||
|
// Phase 6b
|
||||||
|
memberCountsByWindow,
|
||||||
|
inviteJoinCountsByWindow,
|
||||||
|
tableCountsByWindow,
|
||||||
|
recentMemberEvents,
|
||||||
|
recentFilterHits,
|
||||||
|
recentSpamHits,
|
||||||
|
}
|
||||||
105
server/src/model/moderation/moderation.model.js
Normal file
105
server/src/model/moderation/moderation.model.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Business logic for the moderation dashboard: reshapes the raw mod_actions
|
||||||
|
// reads into the shapes the admin UI consumes, and annotates each action with
|
||||||
|
// whether it was an automated (bot) action. For a Discord bot the application_id
|
||||||
|
// IS the bot's user id, and the filter/spam pipeline records automated actions
|
||||||
|
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
|
||||||
|
// staff_user_id === bot_config.application_id reliably flags automated actions
|
||||||
|
// without needing new columns on mod_actions.
|
||||||
|
const moderationDb = require('./moderation.db')
|
||||||
|
const botConfigDb = require('../botConfig/botConfig.db')
|
||||||
|
const { zeroCounts, annotate, reshapeWindows, windowValue } = require('./moderation.pure')
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const WINDOW_KEYS = ['24h', '7d', '30d']
|
||||||
|
|
||||||
|
async function botApplicationId() {
|
||||||
|
try {
|
||||||
|
const cfg = await botConfigDb.get()
|
||||||
|
return cfg ? cfg.application_id : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts by type across 24h / 7d / 30d windows for the overview tiles. Covers
|
||||||
|
// moderation actions (mod_actions) plus the Phase 6b event streams: member
|
||||||
|
// joins/leaves, attributed invite joins, and filter/spam hits.
|
||||||
|
async function summary() {
|
||||||
|
const now = Date.now()
|
||||||
|
const cutoffs = {
|
||||||
|
cutoff24h: new Date(now - DAY_MS),
|
||||||
|
cutoff7d: new Date(now - 7 * DAY_MS),
|
||||||
|
cutoff30d: new Date(now - 30 * DAY_MS),
|
||||||
|
}
|
||||||
|
|
||||||
|
const [modRows, memberRows, inviteRow, filterRow, spamRow] = await Promise.all([
|
||||||
|
moderationDb.countsByWindow(cutoffs),
|
||||||
|
moderationDb.memberCountsByWindow(cutoffs),
|
||||||
|
moderationDb.inviteJoinCountsByWindow(cutoffs),
|
||||||
|
moderationDb.tableCountsByWindow('filter_hits', cutoffs),
|
||||||
|
moderationDb.tableCountsByWindow('spam_hits', cutoffs),
|
||||||
|
])
|
||||||
|
|
||||||
|
const windows = reshapeWindows(modRows).windows
|
||||||
|
const joinRow = memberRows.find((r) => r.event_type === 'join')
|
||||||
|
const leaveRow = memberRows.find((r) => r.event_type === 'leave')
|
||||||
|
for (const w of WINDOW_KEYS) {
|
||||||
|
windows[w].joins = windowValue(joinRow, w)
|
||||||
|
windows[w].leaves = windowValue(leaveRow, w)
|
||||||
|
windows[w].invite_joins = windowValue(inviteRow, w)
|
||||||
|
windows[w].filter_hits = windowValue(filterRow, w)
|
||||||
|
windows[w].spam_hits = windowValue(spamRow, w)
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent event feeds for the overview's secondary panel (Phase 6b).
|
||||||
|
async function members(opts) {
|
||||||
|
return moderationDb.recentMemberEvents(opts)
|
||||||
|
}
|
||||||
|
async function filterHits(opts) {
|
||||||
|
return moderationDb.recentFilterHits(opts)
|
||||||
|
}
|
||||||
|
async function spamHits(opts) {
|
||||||
|
return moderationDb.recentSpamHits(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recent(opts) {
|
||||||
|
const appId = await botApplicationId()
|
||||||
|
return annotate(await moderationDb.recentActions(opts), appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userActions(discordId, opts) {
|
||||||
|
const appId = await botApplicationId()
|
||||||
|
return annotate(await moderationDb.userActions(discordId, opts), appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header data for the per-user history page: latest known tag, linked site
|
||||||
|
// account (if any), and all-time counts per action type.
|
||||||
|
async function userSummary(discordId) {
|
||||||
|
const [countRows, tag, linked] = await Promise.all([
|
||||||
|
moderationDb.userCounts(discordId),
|
||||||
|
moderationDb.latestTag(discordId),
|
||||||
|
moderationDb.linkedAccount(discordId),
|
||||||
|
])
|
||||||
|
const counts = zeroCounts()
|
||||||
|
let total = 0
|
||||||
|
for (const row of countRows) {
|
||||||
|
const c = Number(row.c) || 0
|
||||||
|
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
|
||||||
|
total += c
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
discord_user_id: discordId,
|
||||||
|
tag,
|
||||||
|
linked_account: linked,
|
||||||
|
counts,
|
||||||
|
total_actions: total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(term, opts) {
|
||||||
|
return moderationDb.searchTargets(term, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { summary, recent, userActions, userSummary, search, members, filterHits, spamHits }
|
||||||
47
server/src/model/moderation/moderation.pure.js
Normal file
47
server/src/model/moderation/moderation.pure.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Pure reshaping/annotation helpers for the moderation dashboard, deliberately
|
||||||
|
// free of any DB (or other side-effecting) imports so they can be unit-tested
|
||||||
|
// without opening a database pool. moderation.model re-exports these.
|
||||||
|
|
||||||
|
function zeroCounts() {
|
||||||
|
return { ban: 0, kick: 0, mute: 0, warn: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag each action as automated (staff is the bot) and fold the joined
|
||||||
|
// user_identities columns into a linked_account object. The string coercion
|
||||||
|
// matters — snowflakes can arrive as number or string from different columns.
|
||||||
|
function annotate(rows, appId) {
|
||||||
|
return rows.map((r) => {
|
||||||
|
const isAutomated = appId != null && String(r.staff_user_id) === String(appId)
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
is_automated: isAutomated,
|
||||||
|
linked_account: r.target_site_user_id
|
||||||
|
? { id: r.target_site_user_id, username: r.target_site_username }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the per-type window rows into the { windows: { '24h', '7d', '30d' } }
|
||||||
|
// shape the dashboard tiles consume, zero-filling any type with no rows.
|
||||||
|
function reshapeWindows(rows) {
|
||||||
|
const windows = { '24h': zeroCounts(), '7d': zeroCounts(), '30d': zeroCounts() }
|
||||||
|
for (const row of rows) {
|
||||||
|
const t = row.action_type
|
||||||
|
if (windows['24h'][t] === undefined) continue
|
||||||
|
windows['24h'][t] = Number(row.d1) || 0
|
||||||
|
windows['7d'][t] = Number(row.d7) || 0
|
||||||
|
windows['30d'][t] = Number(row.d30) || 0
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the count for one window key ('24h'|'7d'|'30d') out of a
|
||||||
|
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
|
||||||
|
function windowValue(row, key) {
|
||||||
|
if (!row) return 0
|
||||||
|
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
|
||||||
|
return Number(col) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { zeroCounts, annotate, reshapeWindows, windowValue }
|
||||||
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal file
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// SQL for the revoked_sessions denylist. Rows are keyed on a session's JWT `jti`
|
||||||
|
// and carry the token's own expiry so they can be pruned once the underlying JWT
|
||||||
|
// would fail verification anyway. This is the web/cookie analogue of
|
||||||
|
// mobile_refresh_tokens (opaque, DB-stored, revocable).
|
||||||
|
|
||||||
|
// Add a jti to the denylist. INSERT IGNORE makes a repeat logout of the same
|
||||||
|
// session a harmless no-op (the PK already exists). Returns rows changed.
|
||||||
|
async function add({ jti, userId = null, expiresAt }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT IGNORE INTO revoked_sessions (jti, user_id, expires_at)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
[jti, userId, new Date(expiresAt)],
|
||||||
|
)
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if this jti is on the denylist and not yet past its stored expiry. Past
|
||||||
|
// expiry the token itself is already invalid, so a lingering row need not match.
|
||||||
|
async function isRevoked(jti) {
|
||||||
|
if (!jti) return false
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT 1 FROM revoked_sessions WHERE jti = ? AND expires_at > NOW() LIMIT 1',
|
||||||
|
[jti],
|
||||||
|
)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Housekeeping: drop rows whose token has already expired. Returns rows removed.
|
||||||
|
async function pruneExpired() {
|
||||||
|
const res = await query('DELETE FROM revoked_sessions WHERE expires_at < NOW()')
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
add,
|
||||||
|
isRevoked,
|
||||||
|
pruneExpired,
|
||||||
|
}
|
||||||
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal file
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Web/cookie session denylist. Thin logic layer over revokedSessions.db — mirrors
|
||||||
|
// the users/mobileSessions split (.db = SQL, .model = the API the rest of the app
|
||||||
|
// calls). A "revoked session" is a single JWT jti added on logout; requireAuth
|
||||||
|
// checks isRevoked on every authenticated request. Broad invalidation
|
||||||
|
// ("everywhere" / password change) does NOT live here — it bumps
|
||||||
|
// users.tokens_valid_after instead.
|
||||||
|
|
||||||
|
const db = require('./revokedSessions.db')
|
||||||
|
|
||||||
|
// Add a session's jti to the denylist (single-session logout). Idempotent.
|
||||||
|
async function revoke({ jti, userId, expiresAt }) {
|
||||||
|
return db.add({ jti, userId, expiresAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if the given jti has been revoked (and its token hasn't expired yet).
|
||||||
|
async function isRevoked(jti) {
|
||||||
|
return db.isRevoked(jti)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop denylist rows whose token has already expired.
|
||||||
|
async function pruneExpired() {
|
||||||
|
return db.pruneExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
revoke,
|
||||||
|
isRevoked,
|
||||||
|
pruneExpired,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user