Merge pull request 'Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)' (#27) from feature/swagger-docs into main

Reviewed-on: UOM/website#27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-03 20:30:14 +00:00
11 changed files with 7270 additions and 45 deletions

View File

@@ -23,6 +23,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
- [First admin & site mode](#first-admin--site-mode) - [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).
--- ---

182
server/package-lock.json generated
View File

@@ -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",

View File

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

View File

@@ -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' }))

View File

@@ -24,25 +24,72 @@ const adminOnly = requireRole('admin')
// ── Account security (self-service, any logged-in role) ─────────────── // ── Account security (self-service, any logged-in role) ───────────────
// Not behind adminOnly: an editor manages their own 2FA too. // Not behind adminOnly: an editor manages their own 2FA too.
adminRouter.get('/account', account.getAccount) adminRouter.get(
adminRouter.post('/account/totp/setup', account.totpSetup) '/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/User" } } } } */
/* #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: { type: "object", properties: { otpauth_url: { type: "string" }, qr: { type: "string" } } } } } } */
/* #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( adminRouter.post(
'/account/totp/enable', '/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/Message" } } } } */
/* #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 }), body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate, validate,
account.totpEnable, account.totpEnable,
) )
adminRouter.post( adminRouter.post(
'/account/totp/disable', '/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/Message" } } } } */
/* #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 }), body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate, validate,
account.totpDisable, account.totpDisable,
) )
// Linked SSO identities (self-service — any logged-in role manages their own). // Linked SSO identities (self-service — any logged-in role manages their own).
adminRouter.get('/account/identities', account.listIdentities) 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: { type: "object", properties: { provider: { type: "string" }, email: { type: "string" } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.listIdentities,
)
adminRouter.delete( adminRouter.delete(
'/account/identities/:provider', '/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/Message" } } } } */
/* #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-]+$/), param('provider').matches(/^[a-z0-9-]+$/),
validate, validate,
account.unlinkIdentity, account.unlinkIdentity,
@@ -83,9 +130,26 @@ const upload = multer({
}) })
// ── Dashboard & site mode ───────────────────────────────────────────── // ── Dashboard & site mode ─────────────────────────────────────────────
adminRouter.get('/dashboard', ctrl.dashboard) adminRouter.get(
'/dashboard',
// #swagger.tags = ['Admin · Dashboard']
// #swagger.summary = 'Dashboard summary counts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Summary counts (posts, wiki, users, site mode)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.dashboard,
)
adminRouter.put( adminRouter.put(
'/site-mode', '/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: { type: "object", properties: { mode: { type: "string", example: "maintenance" } } } } } } */
/* #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, adminOnly,
body('mode').isIn(['live', 'maintenance']), body('mode').isIn(['live', 'maintenance']),
validate, validate,
@@ -93,32 +157,133 @@ adminRouter.put(
) )
// ── Posts (news / five-on-friday / newsletter / screenshots) ────────── // ── Posts (news / five-on-friday / newsletter / screenshots) ──────────
adminRouter.get('/posts', ctrl.listPosts) 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( adminRouter.post(
'/posts', '/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('category').isString().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }), body('title').isString().trim().notEmpty().isLength({ max: 200 }),
validate, validate,
ctrl.createPost, ctrl.createPost,
) )
adminRouter.post('/posts/upload', upload.single('image'), ctrl.uploadImage) 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 }. // Generalized upload (rich-text editors). Same multer middleware; returns { url }.
adminRouter.post('/uploads', upload.single('image'), ctrl.uploadFile) adminRouter.post(
adminRouter.get('/posts/:id', param('id').isInt(), validate, ctrl.getPost) '/uploads',
adminRouter.put('/posts/:id', param('id').isInt(), validate, ctrl.updatePost) // #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( adminRouter.patch(
'/posts/:id/publish', '/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(), param('id').isInt(),
body('published').isBoolean(), body('published').isBoolean(),
validate, validate,
ctrl.publishPost, ctrl.publishPost,
) )
adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost) 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', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #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,
)
// ── Wiki categories (static paths registered before /wiki/:slug) ─────── // ── Wiki categories (static paths registered before /wiki/:slug) ───────
adminRouter.get('/wiki/categories', ctrl.listWikiCategories) 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( adminRouter.post(
'/wiki/categories', '/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('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty().isLength({ max: 200 }), body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
@@ -128,6 +293,16 @@ adminRouter.post(
) )
adminRouter.put( adminRouter.put(
'/wiki/categories/:id', '/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(), param('id').isInt(),
body('slug').optional().matches(/^[a-z0-9-]+$/), body('slug').optional().matches(/^[a-z0-9-]+$/),
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }), body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
@@ -136,15 +311,51 @@ adminRouter.put(
validate, validate,
ctrl.updateWikiCategory, ctrl.updateWikiCategory,
) )
adminRouter.delete('/wiki/categories/:id', param('id').isInt(), validate, ctrl.deleteWikiCategory) 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', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #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 ────────────────────────────────────────────────────────── // ── Wiki tags ──────────────────────────────────────────────────────────
adminRouter.get('/wiki/tags', ctrl.listWikiTags) 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 ───────────────────────────────────────────────────────── // ── Wiki pages ─────────────────────────────────────────────────────────
adminRouter.get('/wiki', ctrl.listWiki) 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( adminRouter.post(
'/wiki', '/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('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty().isLength({ max: 200 }), body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
@@ -154,9 +365,28 @@ adminRouter.post(
validate, validate,
ctrl.createWiki, ctrl.createWiki,
) )
adminRouter.get('/wiki/:slug', ctrl.getWiki) 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( adminRouter.put(
'/wiki/:slug', '/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('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }), body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(), body('category_id').optional({ values: 'null' }).isInt(),
@@ -168,33 +398,132 @@ adminRouter.put(
) )
adminRouter.patch( adminRouter.patch(
'/wiki/:slug/publish', '/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(), body('published').isBoolean(),
validate, validate,
ctrl.publishWiki, ctrl.publishWiki,
) )
adminRouter.get('/wiki/:slug/revisions', ctrl.listWikiRevisions) adminRouter.get(
adminRouter.get('/wiki/:slug/revisions/:id', param('id').isInt(), validate, ctrl.getWikiRevision) '/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( adminRouter.post(
'/wiki/:slug/revisions/:id/restore', '/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(), param('id').isInt(),
validate, validate,
ctrl.restoreWikiRevision, ctrl.restoreWikiRevision,
) )
adminRouter.delete('/wiki/:slug', ctrl.deleteWiki) 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', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #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,
)
// ── Settings ────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────
adminRouter.get('/settings', adminOnly, ctrl.getSettings) adminRouter.get(
adminRouter.put('/settings', adminOnly, ctrl.updateSettings) '/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 ────────────────────────────────────────────────────── // ── Activity log ──────────────────────────────────────────────────────
adminRouter.get('/activity', ctrl.listActivity) 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) ───────────────────────────────────────── // ── Bot activity (admin only) ─────────────────────────────────────────
// Read-only view of the botScore middleware's in-memory scoring/ban state and // Read-only view of the botScore middleware's in-memory scoring/ban state and
// recent events, plus an emergency unban for false positives. // recent events, plus an emergency unban for false positives.
adminRouter.get('/bot-activity', adminOnly, botActivity.getBotActivity) 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( adminRouter.post(
'/bot-activity/unban', '/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', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #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, adminOnly,
body('ip').isIP(), body('ip').isIP(),
validate, validate,
@@ -202,9 +531,29 @@ adminRouter.post(
) )
// ── Authentication providers / SSO (admin only) ─────────────────────── // ── Authentication providers / SSO (admin only) ───────────────────────
adminRouter.get('/auth/providers', adminOnly, authProviders.list) 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( adminRouter.post(
'/auth/providers', '/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, adminOnly,
body('id').matches(/^[a-z0-9-]+$/), body('id').matches(/^[a-z0-9-]+$/),
body('kind').isIn(['oidc', 'oauth2']), body('kind').isIn(['oidc', 'oauth2']),
@@ -222,6 +571,16 @@ adminRouter.post(
) )
adminRouter.put( adminRouter.put(
'/auth/providers/:id', '/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, adminOnly,
param('id').matches(/^[a-z0-9-]+$/), param('id').matches(/^[a-z0-9-]+$/),
body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }), body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }),
@@ -238,6 +597,16 @@ adminRouter.put(
) )
adminRouter.delete( adminRouter.delete(
'/auth/providers/:id', '/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/Message" } } } } */
/* #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, adminOnly,
param('id').matches(/^[a-z0-9-]+$/), param('id').matches(/^[a-z0-9-]+$/),
validate, validate,
@@ -246,9 +615,27 @@ adminRouter.delete(
// ── User management (admin only) ────────────────────────────────────── // ── User management (admin only) ──────────────────────────────────────
adminRouter.use('/users', adminOnly) adminRouter.use('/users', adminOnly)
adminRouter.get('/users', ctrl.listUsers) 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( adminRouter.post(
'/users', '/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('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }), body('password').isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor']), body('role').optional().isIn(['admin', 'editor']),
@@ -257,6 +644,17 @@ adminRouter.post(
) )
adminRouter.put( adminRouter.put(
'/users/:id', '/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(), param('id').isInt(),
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }), body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
body('password').optional().isString().isLength({ min: 8, max: 64 }), body('password').optional().isString().isLength({ min: 8, max: 64 }),
@@ -264,6 +662,20 @@ adminRouter.put(
validate, validate,
ctrl.updateUser, ctrl.updateUser,
) )
adminRouter.delete('/users/:id', param('id').isInt(), validate, ctrl.deleteUser) 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', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #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,
)
module.exports = adminRouter module.exports = adminRouter

View File

@@ -26,6 +26,14 @@ const loginGuards = [backoffGuard, slowLogin, loginLimiter]
authRouter.post( authRouter.post(
'/login', '/login',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Log in with username and password'
// #swagger.description = 'On success sets the httpOnly session cookie. If the account has 2FA enabled, returns { totpRequired, challenge } instead and no cookie is set — complete login at POST /login/totp. Rate limited and behind bot/backoff guards.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/LoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued, or TOTP challenge required', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Incorrect username or password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards, ...loginGuards,
body('username').isString().trim().notEmpty(), body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(), body('password').isString().notEmpty(),
@@ -39,6 +47,14 @@ authRouter.post(
// Second factor: same throttling, since it's a code-guessing surface too. // Second factor: same throttling, since it's a code-guessing surface too.
authRouter.post( authRouter.post(
'/login/totp', '/login/totp',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Complete login with a TOTP code'
// #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus the current authenticator code for a session cookie.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards, ...loginGuards,
body('challenge').isString().notEmpty(), body('challenge').isString().notEmpty(),
body('code').isString().trim().isLength({ min: 6, max: 8 }), body('code').isString().trim().isLength({ min: 6, max: 8 }),
@@ -46,7 +62,22 @@ authRouter.post(
loginTotp, loginTotp,
) )
authRouter.post('/logout', logout) authRouter.post(
authRouter.get('/me', isLoggedIn, me) '/logout',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Log out (clear the session cookie)'
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
logout,
)
authRouter.get(
'/me',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Current authenticated user'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The signed-in user', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User" } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
isLoggedIn,
me,
)
module.exports = authRouter module.exports = authRouter

View File

@@ -17,6 +17,14 @@ const loginGuards = [backoffGuard, slowLogin, loginLimiter]
// POST /auth/mobile/login — { username, password, code? } // POST /auth/mobile/login — { username, password, code? }
mobileRouter.post( mobileRouter.post(
'/login', '/login',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Native login → access + refresh tokens'
// #swagger.description = 'Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid credentials, or a TOTP code is required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards, ...loginGuards,
body('username').isString().trim().notEmpty(), body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(), body('password').isString().notEmpty(),
@@ -29,6 +37,14 @@ mobileRouter.post(
// POST /auth/mobile/refresh — { refreshToken } // POST /auth/mobile/refresh — { refreshToken }
mobileRouter.post( mobileRouter.post(
'/refresh', '/refresh',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Rotate a refresh token for a fresh token pair'
// #swagger.description = 'Refresh tokens are single-use: the presented token is revoked and a new access + refresh pair is issued. Reusing a rotated token fails with 401.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileRefreshRequest" } } } } */
/* #swagger.responses[200] = { description: 'New access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid or expired session', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many refresh attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
mobileRefreshLimiter, mobileRefreshLimiter,
body('refreshToken').isString().notEmpty(), body('refreshToken').isString().notEmpty(),
validate, validate,
@@ -38,6 +54,13 @@ mobileRouter.post(
// POST /auth/mobile/logout — { refreshToken?, all? } — requires a valid bearer. // POST /auth/mobile/logout — { refreshToken?, all? } — requires a valid bearer.
mobileRouter.post( mobileRouter.post(
'/logout', '/logout',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Revoke the current (or all) refresh tokens'
// #swagger.description = 'Requires a valid bearer access token. Revokes the given refresh token, or every session for the user when { all: true }. Idempotent.'
// #swagger.security = [{ "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/MobileLogoutRequest" } } } } */
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[401] = { description: 'Missing or invalid bearer token', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
requireAuth, requireAuth,
body('refreshToken').optional().isString(), body('refreshToken').optional().isString(),
body('all').optional().isBoolean(), body('all').optional().isBoolean(),

View File

@@ -7,16 +7,54 @@ const { ssoStartLimiter } = require('../../../middleware/rateLimit')
const ssoRouter = express.Router() const ssoRouter = express.Router()
// Public discovery — the login page reads this to render provider buttons. // Public discovery — the login page reads this to render provider buttons.
ssoRouter.get('/providers', ctrl.listProviders) ssoRouter.get(
'/providers',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'List enabled SSO providers'
// #swagger.description = 'Public discovery used by the login page to render provider buttons. Never exposes secrets.'
/* #swagger.responses[200] = { description: 'Enabled, valid providers', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Provider" } } } } } */
ctrl.listProviders,
)
// Begin login (public) — redirects to the IdP. // Begin login (public) — redirects to the IdP.
ssoRouter.get('/sso/:provider/start', ssoStartLimiter, ctrl.start) ssoRouter.get(
'/sso/:provider/start',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'Begin SSO login (redirect to the IdP)'
// #swagger.description = 'Sets a short-lived signed transaction cookie and 302-redirects to the provider authorize URL. On error redirects back to the login page with an sso_error query param.'
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
// #swagger.parameters['returnTo'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Internal /admin path to return to after login.' }
/* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the login page on error)' } */
ssoStartLimiter,
ctrl.start,
)
// Begin account linking (must be signed in — the tx captures the acting user). // Begin account linking (must be signed in — the tx captures the acting user).
ssoRouter.get('/sso/:provider/link', requireAuth, ctrl.linkStart) ssoRouter.get(
'/sso/:provider/link',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'Begin linking an SSO identity to the current account'
// #swagger.description = 'Requires an authenticated session; the signed transaction captures the acting user so the callback can attach the external identity.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
/* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the account page on error)' } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
requireAuth,
ctrl.linkStart,
)
// OAuth redirect target — completes login or linking. Not behind requireAuth: // OAuth redirect target — completes login or linking. Not behind requireAuth:
// the signed tx cookie authorizes link mode; login mode is link-only anyway. // the signed tx cookie authorizes link mode; login mode is link-only anyway.
ssoRouter.get('/sso/:provider/callback', ctrl.callback) ssoRouter.get(
'/sso/:provider/callback',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'OAuth redirect target — completes login or linking'
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).'
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' }
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' }
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
ctrl.callback,
)
module.exports = ssoRouter module.exports = ssoRouter

View File

@@ -9,10 +9,32 @@ const { contactLimiter } = require('../../../middleware/rateLimit')
const publicRouter = express.Router() const publicRouter = express.Router()
// Always available (so the client can render the maintenance page + contact). // Always available (so the client can render the maintenance page + contact).
publicRouter.get('/settings', ctrl.getSettings) publicRouter.get(
publicRouter.get('/status', ctrl.getStatus) '/settings',
// #swagger.tags = ['Public']
// #swagger.summary = 'Public site settings'
// #swagger.description = 'Whitelisted, non-sensitive settings the client needs to render the site.'
/* #swagger.responses[200] = { description: 'Key/value settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
ctrl.getSettings,
)
publicRouter.get(
'/status',
// #swagger.tags = ['Public']
// #swagger.summary = 'Site mode / status'
// #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.'
/* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "live" } } } } } } */
ctrl.getStatus,
)
publicRouter.post( publicRouter.post(
'/contact', '/contact',
// #swagger.tags = ['Public']
// #swagger.summary = 'Send a contact message'
// #swagger.description = 'Emails the site owner (or falls back to a mailto). Rate limited.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ContactRequest" } } } } */
/* #swagger.responses[200] = { description: 'Message sent', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[429] = { description: 'Too many messages (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[502] = { description: 'Mail delivery failed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
contactLimiter, contactLimiter,
body('message').isString().trim().notEmpty().isLength({ max: 5000 }), body('message').isString().trim().notEmpty().isLength({ max: 5000 }),
body('email').optional({ values: 'falsy' }).isEmail(), body('email').optional({ values: 'falsy' }).isEmail(),
@@ -22,12 +44,62 @@ publicRouter.post(
) )
// Content — gated by site mode (admins with a valid token bypass for preview). // Content — gated by site mode (admins with a valid token bypass for preview).
publicRouter.get('/posts/:category', siteMode, ctrl.getPosts) publicRouter.get(
publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost) '/posts/:category',
publicRouter.get('/wiki', siteMode, ctrl.getWikiList) // #swagger.tags = ['Public']
// #swagger.summary = 'List published posts in a category'
// #swagger.description = 'Gated by site mode: during maintenance only admins with a valid session see content.'
// #swagger.parameters['category'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'news | five-on-friday | newsletter | screenshots' }
/* #swagger.responses[200] = { description: 'Published posts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Post" } } } } } */
/* #swagger.responses[404] = { description: 'Unknown category', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getPosts,
)
publicRouter.get(
'/posts/:category/:idOrSlug',
// #swagger.tags = ['Public']
// #swagger.summary = 'Get a single published post'
// #swagger.parameters['category'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Post category.' }
// #swagger.parameters['idOrSlug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Numeric id or slug.' }
/* #swagger.responses[200] = { description: 'The post', content: { "application/json": { schema: { $ref: "#/components/schemas/Post" } } } } */
/* #swagger.responses[404] = { description: 'Unknown category or post not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getPost,
)
publicRouter.get(
'/wiki',
// #swagger.tags = ['Public']
// #swagger.summary = 'List published wiki pages'
/* #swagger.responses[200] = { description: 'Published wiki pages', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiPage" } } } } } */
siteMode,
ctrl.getWikiList,
)
// Static paths must precede the :slug route so they aren't captured as a slug. // Static paths must precede the :slug route so they aren't captured as a slug.
publicRouter.get('/wiki/categories', siteMode, ctrl.getWikiCategories) publicRouter.get(
publicRouter.get('/wiki/tags', siteMode, ctrl.getWikiTags) '/wiki/categories',
publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage) // #swagger.tags = ['Public']
// #swagger.summary = 'List wiki categories'
/* #swagger.responses[200] = { description: 'Wiki categories', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/WikiCategory" } } } } } */
siteMode,
ctrl.getWikiCategories,
)
publicRouter.get(
'/wiki/tags',
// #swagger.tags = ['Public']
// #swagger.summary = 'List wiki tags'
/* #swagger.responses[200] = { description: 'Wiki tags', content: { "application/json": { schema: { type: "array", items: { type: "string" } } } } } */
siteMode,
ctrl.getWikiTags,
)
publicRouter.get(
'/wiki/:slug',
// #swagger.tags = ['Public']
// #swagger.summary = 'Get a single published wiki page'
// #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[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getWikiPage,
)
module.exports = publicRouter module.exports = publicRouter

File diff suppressed because it is too large Load Diff

340
server/swagger/swagger.js Normal file
View File

@@ -0,0 +1,340 @@
// ── OpenAPI spec generator (swagger-autogen) ───────────────────────────────
//
// Static-analyzes the Express routers and emits `swagger-output.json`, which is
// served by swagger-ui-express at /api/docs (see src/app.js). Per-endpoint
// details — tags, summaries, parameters, request bodies, security and response
// codes — live as `#swagger.*` comments next to each route in
// src/router/**. This file supplies everything shared: API metadata, servers,
// tag descriptions, the two auth schemes (session cookie + mobile bearer), and
// the reusable component schemas the annotations reference.
//
// Regenerate with: npm run swagger (from the server/ directory)
// The generated JSON is committed so the docs work without a build step.
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const pkg = require('../package.json')
const outputFile = './swagger/swagger-output.json'
// Entry point of the routing graph. swagger-autogen follows the `app.use(...)`
// mount chain from here (/api → /v1 → auth|public|admin), so generated paths are
// fully-qualified (e.g. /api/v1/auth/login).
const routes = ['./src/app.js']
const doc = {
info: {
title: 'UOMysticmoon API',
version: pkg.version,
description:
'REST API for the UOMysticmoon website, wiki and admin panel — a private ' +
'Ultima Online shard.\n\n' +
'### Authentication\n' +
'- **Web / admin panel** uses an httpOnly session cookie (`uomm_token`) issued by ' +
'`POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n' +
'- **Native / mobile clients** use bearer access tokens from ' +
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
'restricted to the `admin` role (editors are limited to content).',
},
servers: [
{ url: '/', description: 'Same-origin (current host)' },
{ url: 'http://localhost:3000', description: 'Local development' },
],
tags: [
{ name: 'Health', description: 'Liveness probe' },
{ name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' },
{ name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' },
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
{ name: 'Admin · Activity', description: 'Admin activity log' },
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
{ name: 'Admin · Users', description: 'User management (admin only)' },
],
components: {
securitySchemes: {
// Web/admin session — httpOnly cookie set by the login endpoints.
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: 'uomm_token',
description: 'Session JWT set as an httpOnly cookie by POST /api/v1/auth/login.',
},
// Native/mobile clients — Authorization: Bearer <accessToken>.
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Access token from POST /api/v1/auth/mobile/login (or /refresh).',
},
},
schemas: {
Error: {
type: 'object',
properties: { message: { type: 'string', example: 'Not found' } },
},
ValidationError: {
type: 'object',
properties: {
errors: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', example: 'field' },
msg: { type: 'string', example: 'Invalid value' },
path: { type: 'string', example: 'username' },
location: { type: 'string', example: 'body' },
},
},
},
},
},
SafeUser: {
type: 'object',
properties: {
id: { type: 'integer', example: 1 },
username: { type: 'string', example: 'admin' },
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
},
},
LoginRequest: {
type: 'object',
required: ['username', 'password'],
properties: {
username: { type: 'string', example: 'admin' },
password: { type: 'string', format: 'password', example: 'super-secret' },
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
},
},
LoginResponse: {
type: 'object',
description:
'Either a session (user) or, for 2FA accounts, a TOTP challenge to complete at /login/totp.',
properties: {
user: { $ref: '#/components/schemas/SafeUser' },
totpRequired: { type: 'boolean', example: true },
challenge: { type: 'string', description: 'Signed challenge token for the TOTP step.' },
},
},
TotpLoginRequest: {
type: 'object',
required: ['challenge', 'code'],
properties: {
challenge: { type: 'string', description: 'Token returned by /login when totpRequired.' },
code: { type: 'string', example: '123456' },
},
},
MobileLoginRequest: {
type: 'object',
required: ['username', 'password'],
properties: {
username: { type: 'string', example: 'admin' },
password: { type: 'string', format: 'password', example: 'super-secret' },
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
},
},
MobileTokenResponse: {
type: 'object',
properties: {
accessToken: { type: 'string', description: 'Short-lived bearer JWT.' },
refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' },
expiresIn: { type: 'integer', description: 'Access token lifetime in seconds.', example: 900 },
user: { $ref: '#/components/schemas/SafeUser' },
},
},
MobileRefreshRequest: {
type: 'object',
required: ['refreshToken'],
properties: { refreshToken: { type: 'string' } },
},
MobileLogoutRequest: {
type: 'object',
properties: {
refreshToken: { type: 'string', description: 'Revoke a single session.' },
all: { type: 'boolean', description: 'Revoke every session for the user.', example: false },
},
},
Message: {
type: 'object',
properties: { message: { type: 'string', example: 'Logged out.' } },
},
ContactRequest: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string', maxLength: 5000, example: 'When does the shard launch?' },
email: { type: 'string', format: 'email', example: 'player@example.com' },
name: { type: 'string', maxLength: 100, example: 'Lord British' },
},
},
Provider: {
type: 'object',
properties: {
id: { type: 'string', example: 'google' },
name: { type: 'string', example: 'Google' },
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
},
},
ProviderConfig: {
type: 'object',
properties: {
id: { type: 'string', example: 'okta' },
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
name: { type: 'string', example: 'Okta' },
enabled: { type: 'boolean', example: true },
clientId: { type: 'string' },
authorizeUrl: { type: 'string', format: 'uri' },
tokenUrl: { type: 'string', format: 'uri' },
userinfoUrl: { type: 'string', format: 'uri' },
scopes: { type: 'string', example: 'openid email profile' },
priority: { type: 'integer', example: 10 },
},
},
ProviderCreateRequest: {
type: 'object',
required: ['id', 'kind', 'name'],
properties: {
id: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'okta' },
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
name: { type: 'string', maxLength: 80, example: 'Okta' },
enabled: { type: 'boolean', example: true },
clientId: { type: 'string' },
secret: { type: 'string', format: 'password' },
authorizeUrl: { type: 'string', format: 'uri' },
tokenUrl: { type: 'string', format: 'uri' },
userinfoUrl: { type: 'string', format: 'uri' },
scopes: { type: 'string', maxLength: 500, example: 'openid email profile' },
priority: { type: 'integer', example: 10 },
},
},
Post: {
type: 'object',
properties: {
id: { type: 'integer', example: 12 },
category: { type: 'string', example: 'news' },
title: { type: 'string', example: 'Server maintenance this weekend' },
slug: { type: 'string', example: 'server-maintenance-this-weekend' },
body: { type: 'string' },
image_url: { type: 'string', example: '/uploads/1700000000-abcd.png' },
published: { type: 'boolean', example: true },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' },
},
},
PostCreateRequest: {
type: 'object',
required: ['category', 'title'],
properties: {
category: { type: 'string', example: 'news' },
title: { type: 'string', maxLength: 200, example: 'Server maintenance this weekend' },
body: { type: 'string' },
image_url: { type: 'string', description: 'Required for the screenshots category.' },
published: { type: 'boolean', example: false },
},
},
PublishRequest: {
type: 'object',
required: ['published'],
properties: { published: { type: 'boolean', example: true } },
},
UploadResponse: {
type: 'object',
properties: { url: { type: 'string', example: '/uploads/1700000000-abcd.png' } },
},
WikiPage: {
type: 'object',
properties: {
id: { type: 'integer', example: 3 },
slug: { type: 'string', example: 'getting-started' },
title: { type: 'string', example: 'Getting Started' },
excerpt: { type: 'string' },
body: { type: 'string' },
category_id: { type: 'integer', nullable: true, example: 2 },
published: { type: 'boolean', example: true },
tags: { type: 'array', items: { type: 'string' }, example: ['newbie', 'guide'] },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' },
},
},
WikiPageCreateRequest: {
type: 'object',
required: ['slug', 'title'],
properties: {
slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'getting-started' },
title: { type: 'string', maxLength: 200, example: 'Getting Started' },
excerpt: { type: 'string', maxLength: 400 },
body: { type: 'string' },
category_id: { type: 'integer', nullable: true },
published: { type: 'boolean', example: false },
tags: { type: 'array', items: { type: 'string' } },
},
},
WikiCategory: {
type: 'object',
properties: {
id: { type: 'integer', example: 2 },
slug: { type: 'string', example: 'guides' },
title: { type: 'string', example: 'Guides' },
description: { type: 'string' },
sort_order: { type: 'integer', example: 0 },
},
},
WikiCategoryCreateRequest: {
type: 'object',
required: ['slug', 'title'],
properties: {
slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'guides' },
title: { type: 'string', maxLength: 200, example: 'Guides' },
description: { type: 'string', maxLength: 400 },
sort_order: { type: 'integer', example: 0 },
},
},
User: {
type: 'object',
properties: {
id: { type: 'integer', example: 1 },
username: { type: 'string', example: 'admin' },
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
totp_enabled: { type: 'boolean', example: true },
last_login_at: { type: 'string', format: 'date-time', nullable: true },
created_at: { type: 'string', format: 'date-time' },
},
},
UserCreateRequest: {
type: 'object',
required: ['username', 'password'],
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' },
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
role: { type: 'string', enum: ['admin', 'editor'], example: 'editor' },
},
},
TotpCodeRequest: {
type: 'object',
required: ['code'],
properties: { code: { type: 'string', example: '123456' } },
},
SiteModeRequest: {
type: 'object',
required: ['mode'],
properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' } },
},
UnbanRequest: {
type: 'object',
required: ['ip'],
properties: { ip: { type: 'string', example: '203.0.113.5' } },
},
},
},
}
swaggerAutogen(outputFile, routes, doc).then(() => {
// eslint-disable-next-line no-console
console.log('swagger-output.json generated.')
})