Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.
Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
so no new routes for roster/vendors/char
Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title
Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
1224 lines
81 KiB
JavaScript
1224 lines
81 KiB
JavaScript
const express = require('express')
|
||
const path = require('path')
|
||
const fs = require('fs')
|
||
const crypto = require('crypto')
|
||
const multer = require('multer')
|
||
const { body, param } = require('express-validator')
|
||
|
||
const ctrl = require('./admin.controller')
|
||
const account = require('./account.controller')
|
||
const botActivity = require('./botActivity.controller')
|
||
const authProviders = require('./authProviders.controller')
|
||
const discordBot = require('./discordBot.controller')
|
||
const emailConfig = require('./emailConfig.controller')
|
||
const uoLink = require('./uoLink.controller')
|
||
const usersShard = require('./usersShard.controller')
|
||
const selfShard = require('../player/shard.controller')
|
||
const moderation = require('./moderation.controller')
|
||
const pagesCtrl = require('./pages.controller')
|
||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||
const noindex = require('../../../middleware/noindex')
|
||
const validate = require('../../../middleware/validate')
|
||
|
||
const adminRouter = express.Router()
|
||
|
||
// Every admin route requires auth, a STAFF role, and is kept out of search
|
||
// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted
|
||
// role: without it, the editor-tier routes below (dashboard, posts, wiki,
|
||
// uploads) that are only guarded by isLoggedIn would be reachable by players.
|
||
// Players get 403 here and use the self-scoped /player group instead.
|
||
const staffOnly = requireRole('admin', 'editor', 'moderator')
|
||
adminRouter.use(noindex, isLoggedIn, staffOnly)
|
||
|
||
// Admin-only gate. Editors may manage content (posts/wiki), but user
|
||
// management, site mode, and settings are restricted to the admin role.
|
||
const adminOnly = requireRole('admin')
|
||
|
||
// Moderation-dashboard gate. Moderators get the moderation views; admins can do
|
||
// everything a moderator can. Sensitive writes (admin_only notes) add an extra
|
||
// admin check inside the controller.
|
||
const modAccess = requireRole('admin', 'moderator')
|
||
|
||
// ── Account security (self-service, any logged-in role) ───────────────
|
||
// Not behind adminOnly: an editor manages their own 2FA too.
|
||
adminRouter.get(
|
||
'/account',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Get the current account (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
account.getAccount,
|
||
)
|
||
adminRouter.post(
|
||
'/account/totp/setup',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
account.totpSetup,
|
||
)
|
||
adminRouter.post(
|
||
'/account/totp/enable',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Enable 2FA by confirming a code'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||
validate,
|
||
account.totpEnable,
|
||
)
|
||
adminRouter.post(
|
||
'/account/totp/disable',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Disable 2FA by confirming a code'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||
validate,
|
||
account.totpDisable,
|
||
)
|
||
|
||
// Linked SSO identities (self-service — any logged-in role manages their own).
|
||
adminRouter.get(
|
||
'/account/identities',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'List linked SSO identities (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
account.listIdentities,
|
||
)
|
||
adminRouter.delete(
|
||
'/account/identities/:provider',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('provider').matches(/^[a-z0-9-]+$/),
|
||
validate,
|
||
account.unlinkIdentity,
|
||
)
|
||
|
||
// ── Game account linking (self-service, any staff role) ───────────────
|
||
// Staff link their OWN in-game account here, exactly like players do under
|
||
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||
adminRouter.post(
|
||
'/shard/link',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||
validate,
|
||
selfShard.link,
|
||
)
|
||
adminRouter.get(
|
||
'/shard/accounts',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||
selfShard.listAccounts,
|
||
)
|
||
adminRouter.get(
|
||
'/shard/roster/:account',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('account').matches(SHARD_ACCOUNT_RE),
|
||
validate,
|
||
selfShard.roster,
|
||
)
|
||
adminRouter.get(
|
||
'/shard/vendors/:account',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('account').matches(SHARD_ACCOUNT_RE),
|
||
validate,
|
||
selfShard.vendors,
|
||
)
|
||
adminRouter.get(
|
||
'/shard/char/:serial',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||
validate,
|
||
selfShard.getChar,
|
||
)
|
||
adminRouter.get(
|
||
'/shard/sales',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||
selfShard.getSales,
|
||
)
|
||
|
||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||
const UPLOAD_DIR =
|
||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||
|
||
// Whitelisted image mimetypes → the extension we store the file under. The
|
||
// stored extension is derived from this map (keyed by the accepted mimetype),
|
||
// never from originalname — so a spoofed `Content-Type: image/png` paired with
|
||
// `originalname: x.html` can never land an executable .html file in /uploads.
|
||
const MIME_EXT = {
|
||
'image/png': '.png',
|
||
'image/jpeg': '.jpg',
|
||
'image/gif': '.gif',
|
||
'image/webp': '.webp',
|
||
'image/avif': '.avif',
|
||
}
|
||
|
||
const storage = multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||
filename: (req, file, cb) => {
|
||
const ext = MIME_EXT[file.mimetype] || ''
|
||
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`)
|
||
},
|
||
})
|
||
const upload = multer({
|
||
storage,
|
||
limits: { fileSize: 8 * 1024 * 1024 },
|
||
fileFilter: (req, file, cb) => {
|
||
// Single source of truth: only mimetypes we can map to a safe extension pass.
|
||
if (MIME_EXT[file.mimetype]) cb(null, true)
|
||
else cb(new Error('Only image uploads are allowed'))
|
||
},
|
||
})
|
||
|
||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||
adminRouter.get(
|
||
'/dashboard',
|
||
// #swagger.tags = ['Admin · Dashboard']
|
||
// #swagger.summary = 'Dashboard summary counts'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.dashboard,
|
||
)
|
||
adminRouter.put(
|
||
'/site-mode',
|
||
// #swagger.tags = ['Admin · Dashboard']
|
||
// #swagger.summary = 'Set site mode (admin only)'
|
||
// #swagger.description = 'Switch the site between live and maintenance.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeState" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('mode').isIn(['live', 'maintenance']),
|
||
validate,
|
||
ctrl.setSiteMode,
|
||
)
|
||
|
||
// ── Posts (news / five-on-friday / newsletter / screenshots) ──────────
|
||
adminRouter.get(
|
||
'/posts',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'List all posts (including unpublished)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['category'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Optional category filter.' }
|
||
/* #swagger.responses[200] = { description: 'Posts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Post" } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.listPosts,
|
||
)
|
||
adminRouter.post(
|
||
'/posts',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Create a post'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */
|
||
/* #swagger.responses[201] = { description: 'Created post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('category').isString().notEmpty(),
|
||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||
validate,
|
||
ctrl.createPost,
|
||
)
|
||
adminRouter.post(
|
||
'/posts/upload',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Upload a post image (multipart)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||
/* #swagger.responses[201] = { description: 'Stored image URL', content: { "application/json": { schema: { type: "object", properties: { image_url: { type: "string", example: "/uploads/1700000000-abcd.png" } } } } } } */
|
||
/* #swagger.responses[400] = { description: 'No image / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
upload.single('image'),
|
||
ctrl.uploadImage,
|
||
)
|
||
// Generalized upload (rich-text editors). Same multer middleware; returns { url }.
|
||
adminRouter.post(
|
||
'/uploads',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Upload an image for rich-text editors (multipart)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||
/* #swagger.responses[201] = { description: 'Stored file URL', content: { "application/json": { schema: { $ref: "#/components/schemas/UploadResponse" } } } } */
|
||
/* #swagger.responses[400] = { description: 'No file / disallowed type', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
upload.single('image'),
|
||
ctrl.uploadFile,
|
||
)
|
||
adminRouter.get(
|
||
'/posts/:id',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Get a post by id'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||
/* #swagger.responses[200] = { description: 'The post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.getPost,
|
||
)
|
||
adminRouter.put(
|
||
'/posts/:id',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Update a post'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/PostCreateRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.updatePost,
|
||
)
|
||
adminRouter.patch(
|
||
'/posts/:id/publish',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Publish / unpublish a post'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
body('published').isBoolean(),
|
||
validate,
|
||
ctrl.publishPost,
|
||
)
|
||
adminRouter.delete(
|
||
'/posts/:id',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Delete a post'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.deletePost,
|
||
)
|
||
adminRouter.get(
|
||
'/posts/:id/announce',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Get the announcement pipeline status for a post'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||
/* #swagger.responses[200] = { description: 'The announce job for the post, or null if never announced', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.getAnnounceStatus,
|
||
)
|
||
adminRouter.post(
|
||
'/posts/:id/announce/retry',
|
||
// #swagger.tags = ['Admin · Posts']
|
||
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
body('leg').isIn(['towncrier', 'discord']),
|
||
validate,
|
||
ctrl.retryAnnounceLeg,
|
||
)
|
||
|
||
// ── Wiki categories (static paths registered before /wiki/:slug) ───────
|
||
adminRouter.get(
|
||
'/wiki/categories',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'List wiki categories'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Wiki categories', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiCategory" } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.listWikiCategories,
|
||
)
|
||
adminRouter.post(
|
||
'/wiki/categories',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Create a wiki category'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */
|
||
/* #swagger.responses[201] = { description: 'Created category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('slug').matches(/^[a-z0-9-]+$/),
|
||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||
body('sort_order').optional().isInt(),
|
||
validate,
|
||
ctrl.createWikiCategory,
|
||
)
|
||
adminRouter.put(
|
||
'/wiki/categories/:id',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Update a wiki category'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' }
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategoryCreateRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated category', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiCategory" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
body('slug').optional().matches(/^[a-z0-9-]+$/),
|
||
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
|
||
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||
body('sort_order').optional().isInt(),
|
||
validate,
|
||
ctrl.updateWikiCategory,
|
||
)
|
||
adminRouter.delete(
|
||
'/wiki/categories/:id',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Delete a wiki category'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' }
|
||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.deleteWikiCategory,
|
||
)
|
||
|
||
// ── Wiki tags ──────────────────────────────────────────────────────────
|
||
adminRouter.get(
|
||
'/wiki/tags',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'List wiki tags'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Wiki tags', content: { "application/json": { schema: { type: "array", items: { type: "string" } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.listWikiTags,
|
||
)
|
||
|
||
// ── Wiki pages ─────────────────────────────────────────────────────────
|
||
adminRouter.get(
|
||
'/wiki',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'List all wiki pages (including unpublished)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Wiki pages', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiPage" } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.listWiki,
|
||
)
|
||
adminRouter.post(
|
||
'/wiki',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Create a wiki page'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPageCreateRequest" } } } } */
|
||
/* #swagger.responses[201] = { description: 'Created wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('slug').matches(/^[a-z0-9-]+$/),
|
||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||
body('category_id').optional({ values: 'null' }).isInt(),
|
||
body('published').optional().isBoolean(),
|
||
body('tags').optional().isArray(),
|
||
validate,
|
||
ctrl.createWiki,
|
||
)
|
||
adminRouter.get(
|
||
'/wiki/:slug',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Get a wiki page by slug'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||
/* #swagger.responses[200] = { description: 'The wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.getWiki,
|
||
)
|
||
adminRouter.put(
|
||
'/wiki/:slug',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Update a wiki page (creates a revision)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { allOf: [ { $ref: "#/components/schemas/WikiPageCreateRequest" }, { type: "object", properties: { change_note: { type: "string", maxLength: 280 } } } ] } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error or unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
|
||
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
|
||
body('category_id').optional({ values: 'null' }).isInt(),
|
||
body('published').optional().isBoolean(),
|
||
body('tags').optional().isArray(),
|
||
body('change_note').optional({ values: 'falsy' }).isString().isLength({ max: 280 }),
|
||
validate,
|
||
ctrl.updateWiki,
|
||
)
|
||
adminRouter.patch(
|
||
'/wiki/:slug/publish',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Publish / unpublish a wiki page'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PublishRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('published').isBoolean(),
|
||
validate,
|
||
ctrl.publishWiki,
|
||
)
|
||
adminRouter.get(
|
||
'/wiki/:slug/revisions',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'List revisions of a wiki page'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||
/* #swagger.responses[200] = { description: 'Revisions', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.listWikiRevisions,
|
||
)
|
||
adminRouter.get(
|
||
'/wiki/:slug/revisions/:id',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Get a single wiki revision'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id.' }
|
||
/* #swagger.responses[200] = { description: 'The revision', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.getWikiRevision,
|
||
)
|
||
adminRouter.post(
|
||
'/wiki/:slug/revisions/:id/restore',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Restore a wiki page to a revision'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Revision id to restore.' }
|
||
/* #swagger.responses[200] = { description: 'Restored wiki page', content: { "application/json": { schema: { $ref: "#/components/schemas/WikiPage" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.restoreWikiRevision,
|
||
)
|
||
adminRouter.delete(
|
||
'/wiki/:slug',
|
||
// #swagger.tags = ['Admin · Wiki']
|
||
// #swagger.summary = 'Delete a wiki page'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
|
||
/* #swagger.responses[200] = { description: 'Deleted (echoes the slug)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedSlug" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.deleteWiki,
|
||
)
|
||
|
||
// ── CMS Pages (block-based page builder) ──────────────────────────────
|
||
adminRouter.get(
|
||
'/pages',
|
||
// #swagger.tags = ['Admin · Pages']
|
||
// #swagger.summary = 'List all CMS pages (summaries)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||
pagesCtrl.listPages,
|
||
)
|
||
adminRouter.post(
|
||
'/pages',
|
||
// #swagger.tags = ['Admin · Pages']
|
||
// #swagger.summary = 'Create a CMS page'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
|
||
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('slug').isString().trim().notEmpty(),
|
||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||
validate,
|
||
pagesCtrl.createPage,
|
||
)
|
||
adminRouter.get(
|
||
'/pages/:id',
|
||
// #swagger.tags = ['Admin · Pages']
|
||
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
pagesCtrl.getPage,
|
||
)
|
||
adminRouter.patch(
|
||
'/pages/:id',
|
||
// #swagger.tags = ['Admin · Pages']
|
||
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
|
||
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
pagesCtrl.updatePage,
|
||
)
|
||
adminRouter.delete(
|
||
'/pages/:id',
|
||
// #swagger.tags = ['Admin · Pages']
|
||
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
pagesCtrl.deletePage,
|
||
)
|
||
adminRouter.post(
|
||
'/pages/:id/unprotect',
|
||
// #swagger.tags = ['Admin · Pages']
|
||
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
|
||
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
body('password').isString().notEmpty(),
|
||
validate,
|
||
pagesCtrl.unprotectPage,
|
||
)
|
||
adminRouter.post(
|
||
'/pages/:id/preview',
|
||
// #swagger.tags = ['Admin · Pages']
|
||
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
pagesCtrl.createPreview,
|
||
)
|
||
|
||
// ── Settings ──────────────────────────────────────────────────────────
|
||
adminRouter.get(
|
||
'/settings',
|
||
// #swagger.tags = ['Admin · Settings']
|
||
// #swagger.summary = 'Get all site settings (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'All settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
ctrl.getSettings,
|
||
)
|
||
adminRouter.put(
|
||
'/settings',
|
||
// #swagger.tags = ['Admin · Settings']
|
||
// #swagger.summary = 'Update site settings (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
ctrl.updateSettings,
|
||
)
|
||
|
||
// ── Activity log ──────────────────────────────────────────────────────
|
||
adminRouter.get(
|
||
'/activity',
|
||
// #swagger.tags = ['Admin · Activity']
|
||
// #swagger.summary = 'List recent admin activity'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows to return.' }
|
||
/* #swagger.responses[200] = { description: 'Activity entries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.listActivity,
|
||
)
|
||
|
||
// ── Bot activity (admin only) ─────────────────────────────────────────
|
||
// Read-only view of the botScore middleware's in-memory scoring/ban state and
|
||
// recent events, plus an emergency unban for false positives.
|
||
adminRouter.get(
|
||
'/bot-activity',
|
||
// #swagger.tags = ['Admin · Bot Activity']
|
||
// #swagger.summary = 'Bot-scoring / ban state and recent events (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Banned IPs, scores and recent events', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
botActivity.getBotActivity,
|
||
)
|
||
adminRouter.post(
|
||
'/bot-activity/unban',
|
||
// #swagger.tags = ['Admin · Bot Activity']
|
||
// #swagger.summary = 'Emergency unban an IP (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Unbanned (echoes the ip and whether an entry was cleared)', content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanResult" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('ip').isIP(),
|
||
validate,
|
||
botActivity.unbanIp,
|
||
)
|
||
|
||
// ── Discord bot control (admin only) ──────────────────────────────────
|
||
// Phase 1: entering/enabling the bot token here — never an env var. The token
|
||
// is write-only over this API (SECURITY note in discordBot.controller.js).
|
||
adminRouter.get(
|
||
'/discord-bot/config',
|
||
// #swagger.tags = ['Admin · Discord Bot']
|
||
// #swagger.summary = 'Get Discord bot config + live status (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Masked config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
discordBot.getConfig,
|
||
)
|
||
adminRouter.put(
|
||
'/discord-bot/config',
|
||
// #swagger.tags = ['Admin · Discord Bot']
|
||
// #swagger.summary = 'Save Discord bot config (admin only)'
|
||
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one unchanged.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { guildId: { type: "string" }, token: { type: "string" }, enabled: { type: "boolean" } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error, invalid token, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('guildId').optional({ values: 'falsy' }).isString().trim(),
|
||
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||
body('enabled').optional().isBoolean(),
|
||
validate,
|
||
discordBot.saveConfig,
|
||
)
|
||
|
||
// ── Email delivery (Gmail OAuth2, admin only) ─────────────────────────
|
||
// Modern replacement for env SMTP: the refresh token is captured by the connect
|
||
// flow and is write-only over this API (stored encrypted, never returned).
|
||
adminRouter.get(
|
||
'/email/config',
|
||
// #swagger.tags = ['Admin · Email']
|
||
// #swagger.summary = 'Get email delivery config + status (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
emailConfig.getConfig,
|
||
)
|
||
adminRouter.put(
|
||
'/email/config',
|
||
// #swagger.tags = ['Admin · Email']
|
||
// #swagger.summary = 'Update email delivery config (admin only)'
|
||
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
|
||
body('enabled').optional().isBoolean(),
|
||
validate,
|
||
emailConfig.saveConfig,
|
||
)
|
||
adminRouter.get(
|
||
'/email/connect/start',
|
||
// #swagger.tags = ['Admin · Email']
|
||
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
|
||
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
|
||
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
emailConfig.connectStart,
|
||
)
|
||
adminRouter.get(
|
||
'/email/connect/callback',
|
||
// #swagger.tags = ['Admin · Email']
|
||
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
|
||
adminOnly,
|
||
emailConfig.connectCallback,
|
||
)
|
||
adminRouter.post(
|
||
'/email/test',
|
||
// #swagger.tags = ['Admin · Email']
|
||
// #swagger.summary = 'Send a test email (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
|
||
/* #swagger.responses[502] = { description: 'Send failed / not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('to').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||
validate,
|
||
emailConfig.testSend,
|
||
)
|
||
adminRouter.post(
|
||
'/email/disconnect',
|
||
// #swagger.tags = ['Admin · Email']
|
||
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
emailConfig.disconnect,
|
||
)
|
||
|
||
// ── Authentication providers / SSO (admin only) ───────────────────────
|
||
adminRouter.get(
|
||
'/auth/providers',
|
||
// #swagger.tags = ['Admin · Auth Providers']
|
||
// #swagger.summary = 'List configured SSO providers (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
authProviders.list,
|
||
)
|
||
adminRouter.post(
|
||
'/auth/providers',
|
||
// #swagger.tags = ['Admin · Auth Providers']
|
||
// #swagger.summary = 'Create a custom SSO provider (admin only)'
|
||
// #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||
/* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('id').matches(/^[a-z0-9-]+$/),
|
||
body('kind').isIn(['oidc', 'oauth2']),
|
||
body('name').isString().trim().notEmpty().isLength({ max: 80 }),
|
||
body('enabled').optional().isBoolean(),
|
||
body('clientId').optional({ values: 'falsy' }).isString(),
|
||
body('secret').optional({ values: 'falsy' }).isString(),
|
||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||
body('priority').optional().isInt(),
|
||
validate,
|
||
authProviders.create,
|
||
)
|
||
adminRouter.put(
|
||
'/auth/providers/:id',
|
||
// #swagger.tags = ['Admin · Auth Providers']
|
||
// #swagger.summary = 'Update an SSO provider (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
param('id').matches(/^[a-z0-9-]+$/),
|
||
body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }),
|
||
body('enabled').optional().isBoolean(),
|
||
body('clientId').optional({ values: 'falsy' }).isString(),
|
||
body('secret').optional({ values: 'falsy' }).isString(),
|
||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||
body('priority').optional().isInt(),
|
||
validate,
|
||
authProviders.update,
|
||
)
|
||
adminRouter.delete(
|
||
'/auth/providers/:id',
|
||
// #swagger.tags = ['Admin · Auth Providers']
|
||
// #swagger.summary = 'Delete a custom SSO provider (admin only)'
|
||
// #swagger.description = 'Built-in providers cannot be deleted — disable them instead.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
param('id').matches(/^[a-z0-9-]+$/),
|
||
validate,
|
||
authProviders.remove,
|
||
)
|
||
|
||
// ── Moderation dashboard (admin + moderator) ──────────────────────────
|
||
// Read-only views over the bot's mod_actions log, plus staff notes. The whole
|
||
// sub-path is gated for the moderator role (admins included).
|
||
adminRouter.use('/moderation', modAccess)
|
||
adminRouter.get(
|
||
'/moderation/stats/summary',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Moderation action counts for 24h/7d/30d (admin or moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
moderation.getSummary,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/recent',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Recent moderation actions, optionally filtered by type'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
moderation.getRecent,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/search',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Look up moderated users by Discord id or username snapshot'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
moderation.search,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/members',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Recent member join/leave events (optionally filtered by type)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
moderation.getMembers,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/filter-hits',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Recent automated content-filter hits'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
moderation.getFilterHits,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/spam-hits',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Recent automated spam-detection hits'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
moderation.getSpamHits,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/user/:discordId',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||
validate,
|
||
moderation.getUser,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/user/:discordId/actions',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Full moderation action history for a user'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||
validate,
|
||
moderation.getUserActions,
|
||
)
|
||
adminRouter.get(
|
||
'/moderation/user/:discordId/notes',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Staff notes for a user (admin_only notes hidden from moderators)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||
validate,
|
||
moderation.getUserNotes,
|
||
)
|
||
adminRouter.post(
|
||
'/moderation/user/:discordId/notes',
|
||
// #swagger.tags = ['Admin · Moderation']
|
||
// #swagger.summary = 'Add a staff note (admin_only visibility requires the admin role)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
|
||
body('visibility').optional().isIn(['staff_only', 'admin_only']),
|
||
validate,
|
||
moderation.addUserNote,
|
||
)
|
||
|
||
// ── User management (admin only) ──────────────────────────────────────
|
||
adminRouter.use('/users', adminOnly)
|
||
adminRouter.get(
|
||
'/users',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'List users (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
ctrl.listUsers,
|
||
)
|
||
adminRouter.post(
|
||
'/users',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Create a user (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
||
/* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.createUser,
|
||
)
|
||
adminRouter.put(
|
||
'/users/:id',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Update a user (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
||
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
||
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||
body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }),
|
||
validate,
|
||
ctrl.updateUser,
|
||
)
|
||
adminRouter.delete(
|
||
'/users/:id',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Delete a user (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
ctrl.deleteUser,
|
||
)
|
||
|
||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||
// already grants admins a bypass to any account), so no routes for them here.
|
||
adminRouter.get(
|
||
'/users/:id',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Get a single user (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersShard.getUser,
|
||
)
|
||
adminRouter.get(
|
||
'/users/:id/shard/accounts',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersShard.listAccounts,
|
||
)
|
||
adminRouter.get(
|
||
'/users/:id/shard/sales',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersShard.getSales,
|
||
)
|
||
adminRouter.get(
|
||
'/users/:id/shard/houses',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersShard.getHouses,
|
||
)
|
||
adminRouter.get(
|
||
'/users/:id/shard/online',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersShard.getOnline,
|
||
)
|
||
|
||
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
||
adminRouter.get(
|
||
'/uo-link/config',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
uoLink.getConfig,
|
||
)
|
||
adminRouter.put(
|
||
'/uo-link/config',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Save uo-link connection config (admin only)'
|
||
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
|
||
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||
body('protocol').optional().isInt({ min: 1, max: 99 }),
|
||
body('enabled').optional().isBoolean(),
|
||
validate,
|
||
uoLink.saveConfig,
|
||
)
|
||
adminRouter.post(
|
||
'/uo-link/towncrier',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||
body('lines').isArray({ min: 1, max: 8 }),
|
||
body('lines.*').isString().isLength({ max: 200 }),
|
||
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
|
||
validate,
|
||
uoLink.postTownCrier,
|
||
)
|
||
adminRouter.delete(
|
||
'/uo-link/towncrier/:id',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Remove a town-crier message (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
|
||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
param('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
uoLink.deleteTownCrier,
|
||
)
|
||
adminRouter.get(
|
||
'/uo-link/stream',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
|
||
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||
adminOnly,
|
||
uoLink.stream,
|
||
)
|
||
|
||
module.exports = adminRouter
|