Add pages API: model, controller, routes, preview (steps 4/6/7/8)

Backend for the CMS page builder, all under the existing /api/v1:

- pages.model: authoritative save gate — validates blocks against the
  registry and sanitizes them on every create/update; maps rows to/from the
  grouped API shape (metadata / settings); slug validated + reserved-checked
  at create and immutable after; `protected` can be set true via PATCH but
  only cleared via the unprotect path; published_at stamped on first publish.
- sanitizeBlocks: post-validation normalizer (applies each block's sanitize,
  stamps version, defaults visible, recurses container slots).
- reservedSlugs: guards page slugs from shadowing named routes/API namespaces.
- Admin routes (staff-gated): GET/POST /pages, GET/PATCH/DELETE /pages/:id,
  POST /pages/:id/unprotect (password step-up, verified against the caller's
  own hash, never logged), POST /pages/:id/preview (1h token). Audit-logs
  create/publish/unpublish/protect/unprotect/delete.
- Public routes: GET /public/pages/:slug (published; staff see drafts; site-
  mode gated) and GET /public/pages/:id/preview/:token (ungated, token is the
  access control). Preview token primitives added to auth/token.js.
- Swagger annotations for all new endpoints.

Verified end-to-end: model integration test against the dev DB (sanitize,
invalid-block rejection, slug immutability, protected/unprotect, dup/reserved
slug, published_at) + authenticated HTTP smoke (201 create, 400 invalid
blocks, publish, public slug fetch, preview mint+fetch, 403 delete-protected,
401 wrong-password unprotect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:48:31 -05:00
parent 764fb0c069
commit 4d87c5f627
10 changed files with 770 additions and 0 deletions

View File

@@ -66,6 +66,20 @@ function verifyTotpChallenge(token) {
return decoded return decoded
} }
// Short-lived, unguessable link token for previewing a (possibly unpublished)
// CMS page. Carries purpose:'page_preview' + the page id and nothing else; it is
// NOT a session (session validation rejects it) and only grants read of that one
// page's current block state. Default 1h expiry per the page-builder spec.
function signPagePreview(pageId, { expiresIn = '1h' } = {}) {
return jwt.sign({ pageId, purpose: 'page_preview' }, JWT_SECRET, { expiresIn })
}
function verifyPagePreview(token) {
const decoded = verifyToken(token)
if (!decoded || decoded.purpose !== 'page_preview') return null
return decoded
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m). // Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() { function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim()) const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
@@ -122,6 +136,8 @@ module.exports = {
verifyToken, verifyToken,
signTotpChallenge, signTotpChallenge,
verifyTotpChallenge, verifyTotpChallenge,
signPagePreview,
verifyPagePreview,
cookieMaxAge, cookieMaxAge,
cookieSecure, cookieSecure,
cookieOptions, cookieOptions,

View File

@@ -10,6 +10,7 @@
const registry = require('./registry') const registry = require('./registry')
const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks') const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks')
const { sanitizeBlocks } = require('./sanitizeBlocks')
// ── Wave 1 block definitions (self-register on require) ──────────────── // ── Wave 1 block definitions (self-register on require) ────────────────
require('./types/heading') require('./types/heading')
@@ -23,6 +24,7 @@ require('./types/quote')
module.exports = { module.exports = {
...registry, ...registry,
validateBlocks, validateBlocks,
sanitizeBlocks,
MAX_BLOCKS, MAX_BLOCKS,
MAX_SUBBLOCKS, MAX_SUBBLOCKS,
} }

View File

@@ -0,0 +1,47 @@
// Normalize + sanitize a validated blocks array before persisting. Runs AFTER
// validateBlocks (which guarantees the envelope/prop shape), so this can assume
// well-formed input and focus on: applying each block's registry `sanitize`
// normalizer (e.g. rich_text runs its html through the allowlist), stamping the
// registry `version`, defaulting `visible` to true, and recursing one level into
// container slots. Returns a new array; never mutates the input.
const { getBlock } = require('./registry')
function sanitizeBlocks(blocks) {
if (!Array.isArray(blocks)) return []
return blocks.map(sanitizeOne)
}
function sanitizeOne(block) {
const def = getBlock(block.type)
if (!def) return block // unreachable after validation, but stay defensive
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
// Recurse into container slots first (leaf sub-blocks get sanitized too).
if (def.container) {
for (const slot of def.containerSlots) {
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
}
}
// Apply the block's own normalizer last (operates on its scalar props).
if (def.sanitize) {
try {
props = def.sanitize(props)
} catch {
// Leave props as-is; validation already passed, a sanitize throw shouldn't
// block the save.
}
}
return {
id: block.id,
type: block.type,
version: Number.isInteger(block.version) ? block.version : def.version,
visible: block.visible !== false,
props,
}
}
module.exports = { sanitizeBlocks }

View File

@@ -0,0 +1,79 @@
const { query } = require('../../utils/db')
// `blocks` is stored as a JSON string (MEDIUMTEXT) and parsed in the model.
const COLS = [
'id', 'slug', 'title', 'blocks', 'status', 'protected', 'author_id',
'seo_title', 'meta_description', 'og_image', 'canonical_url', 'robots',
'layout', 'show_in_nav', 'nav_group', 'nav_order',
'created_at', 'updated_at', 'published_at',
].join(', ')
// Admin list — every page, newest first. Excludes the (potentially large)
// blocks payload; callers that need it fetch the row by id/slug.
async function listSummaries() {
return query(
`SELECT id, slug, title, status, protected, show_in_nav, nav_group, nav_order,
updated_at, published_at
FROM pages ORDER BY updated_at DESC, id DESC`,
)
}
async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findBySlug(slug) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
// Insert a fully-formed column map. `blocks` must already be a JSON string.
async function insert(page) {
const res = await query(
`INSERT INTO pages
(slug, title, blocks, status, protected, author_id,
seo_title, meta_description, og_image, canonical_url, robots,
layout, show_in_nav, nav_group, nav_order, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
page.slug,
page.title,
page.blocks,
page.status,
page.protected ? 1 : 0,
page.author_id ?? null,
page.seo_title ?? null,
page.meta_description ?? null,
page.og_image ?? null,
page.canonical_url ?? null,
page.robots ?? null,
page.layout ?? 'default',
page.show_in_nav ? 1 : 0,
page.nav_group ?? null,
page.nav_order ?? null,
page.published_at ?? null,
],
)
return res.insertId
}
// Update only the provided columns. Keys must be real column names (the model
// builds this map from a whitelist, never straight from the request body).
async function update(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE pages SET ${cols.join(', ')} WHERE id = ?`, params)
}
async function remove(id) {
return query('DELETE FROM pages WHERE id = ?', [id])
}
module.exports = { listSummaries, findById, findBySlug, insert, update, remove }

View File

@@ -0,0 +1,306 @@
// CMS pages model. Owns the rules the API surface must not bypass:
// - blocks are validated against the block registry and sanitized on every
// save (the authoritative gate — a direct API call can't skip it);
// - the DB row is mapped to/from the grouped API shape (metadata / settings);
// - slug is validated + reserved-checked at create and is immutable after;
// - `protected` can be turned ON via a normal update but only OFF via the
// dedicated unprotect path (see unprotect()), enforced here regardless of
// what the request body contains.
//
// Business/validation failures throw a PageError carrying an HTTP status + code
// so the controller can translate without knowing the rules.
const pagesDb = require('./pages.db')
const { isReservedSlug } = require('./reservedSlugs')
const { validateBlocks, sanitizeBlocks } = require('../../blocks')
const LAYOUTS = ['default', 'full_width', 'landing']
const NAV_GROUPS = ['main', 'footer', 'account', 'hidden']
const STATUSES = ['draft', 'published']
const SLUG_RE = /^[a-z0-9-]+$/
const MAX_SLUG = 160
class PageError extends Error {
constructor(status, code, message, extra) {
super(message)
this.name = 'PageError'
this.status = status
this.code = code
if (extra) Object.assign(this, extra)
}
}
// ── Serialization (row → API shape) ───────────────────────────────────
function parseBlocks(raw) {
if (raw == null || raw === '') return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function serialize(row) {
if (!row) return null
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
blocks: parseBlocks(row.blocks),
metadata: {
seoTitle: row.seo_title,
metaDescription: row.meta_description,
ogImage: row.og_image,
canonicalUrl: row.canonical_url,
robots: row.robots,
},
settings: {
layout: row.layout,
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
protected: Boolean(row.protected),
},
authorId: row.author_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
function serializeSummary(row) {
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
protected: Boolean(row.protected),
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
// ── Field validation / mapping ────────────────────────────────────────
function assertSlug(slug) {
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > MAX_SLUG) {
throw new PageError(400, 'invalid_slug', 'Slug must be lowercase letters, numbers and dashes.')
}
if (isReservedSlug(slug)) {
throw new PageError(400, 'reserved_slug', `"${slug}" is a reserved slug.`)
}
}
function assertStatus(status) {
if (status !== undefined && !STATUSES.includes(status)) {
throw new PageError(400, 'invalid_status', `status must be one of ${STATUSES.join(', ')}.`)
}
}
// Validate + sanitize blocks; returns a JSON string ready to store.
function buildBlocks(blocks) {
const { valid, errors } = validateBlocks(blocks)
if (!valid) {
throw new PageError(400, 'invalid_blocks', 'One or more blocks are invalid.', { errors })
}
return JSON.stringify(sanitizeBlocks(blocks))
}
// Map the grouped `metadata` object to DB columns. Only keys present in the
// input are returned, so a PATCH touches only what it sends.
function mapMetadata(metadata) {
const cols = {}
if (!metadata || typeof metadata !== 'object') return cols
const strOrNull = (v, max, field) => {
if (v === null || v === undefined || v === '') return null
if (typeof v !== 'string' || v.length > max) {
throw new PageError(400, 'invalid_metadata', `${field} must be a string of at most ${max} characters.`)
}
return v
}
if ('seoTitle' in metadata) cols.seo_title = strOrNull(metadata.seoTitle, 200, 'seoTitle')
if ('metaDescription' in metadata) cols.meta_description = strOrNull(metadata.metaDescription, 400, 'metaDescription')
if ('ogImage' in metadata) cols.og_image = strOrNull(metadata.ogImage, 500, 'ogImage')
if ('canonicalUrl' in metadata) cols.canonical_url = strOrNull(metadata.canonicalUrl, 500, 'canonicalUrl')
if ('robots' in metadata) cols.robots = strOrNull(metadata.robots, 100, 'robots')
return cols
}
// Map the grouped `settings` object to DB columns (except `protected`, which is
// handled by the caller so the unprotect rule stays centralized).
function mapSettings(settings) {
const cols = {}
if (!settings || typeof settings !== 'object') return cols
if ('layout' in settings) {
if (!LAYOUTS.includes(settings.layout)) {
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
}
cols.layout = settings.layout
}
if ('showInNav' in settings) {
if (typeof settings.showInNav !== 'boolean') {
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
}
cols.show_in_nav = settings.showInNav ? 1 : 0
}
if ('navGroup' in settings) {
if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) {
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
}
cols.nav_group = settings.navGroup
}
if ('navOrder' in settings) {
if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) {
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
}
cols.nav_order = settings.navOrder
}
return cols
}
// ── Reads ─────────────────────────────────────────────────────────────
async function list() {
const rows = await pagesDb.listSummaries()
return rows.map(serializeSummary)
}
async function getById(id) {
return serialize(await pagesDb.findById(id))
}
// Public read by slug. Non-admins only see published pages (returns null for a
// draft so the caller can 404 it indistinguishably from a missing page).
async function getBySlug(slug, { includeUnpublished = false } = {}) {
const row = await pagesDb.findBySlug(slug)
if (!row) return null
if (!includeUnpublished && row.status !== 'published') return null
return serialize(row)
}
// Raw row (for the controller's protected/status checks without re-serializing).
async function getRawById(id) {
return pagesDb.findById(id)
}
// ── Writes ────────────────────────────────────────────────────────────
async function create(input, authorId) {
const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
assertSlug(slug)
assertStatus(status)
if (typeof title !== 'string' || title.trim() === '' || title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
const row = {
slug,
title: title.trim(),
blocks: buildBlocks(blocks),
status,
author_id: authorId,
...mapMetadata(metadata),
...mapSettings(settings),
protected: settings && settings.protected === true ? 1 : 0,
published_at: status === 'published' ? new Date() : null,
}
let id
try {
id = await pagesDb.insert(row)
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
throw new PageError(409, 'slug_taken', `A page with slug "${slug}" already exists.`)
}
throw err
}
return getById(id)
}
async function update(id, patch) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
// slug is immutable after create — reject an attempt rather than silently
// ignoring it, so the caller knows their change didn't take.
if (patch.slug !== undefined && patch.slug !== current.slug) {
throw new PageError(400, 'slug_immutable', 'A page slug cannot be changed after creation.')
}
const fields = {}
if (patch.title !== undefined) {
if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
fields.title = patch.title.trim()
}
if (patch.blocks !== undefined) {
fields.blocks = buildBlocks(patch.blocks)
}
if (patch.status !== undefined) {
assertStatus(patch.status)
fields.status = patch.status
// Stamp published_at the first time a page becomes published.
if (patch.status === 'published' && !current.published_at) {
fields.published_at = new Date()
}
}
Object.assign(fields, mapMetadata(patch.metadata))
Object.assign(fields, mapSettings(patch.settings))
// Protected transitions: ON is allowed here; OFF is not (must go through the
// password-gated unprotect endpoint), regardless of the request body.
if (patch.settings && 'protected' in patch.settings) {
const want = patch.settings.protected
if (want === true) {
fields.protected = 1
} else if (want === false && current.protected) {
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
}
// want === false while already unprotected → no-op.
}
await pagesDb.update(id, fields)
return getById(id)
}
async function remove(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
if (current.protected) {
throw new PageError(403, 'page_protected', 'This page is protected and cannot be deleted.')
}
await pagesDb.remove(id)
return { id }
}
// Flip protected → false. The controller performs the password step-up before
// calling this; the model just applies it.
async function unprotect(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
await pagesDb.update(id, { protected: 0 })
return getById(id)
}
module.exports = {
PageError,
LAYOUTS,
NAV_GROUPS,
STATUSES,
serialize,
list,
getById,
getBySlug,
getRawById,
create,
update,
remove,
unprotect,
}

View File

@@ -0,0 +1,28 @@
// Slugs a CMS page may not claim, because a top-level page lives at `/:slug` and
// must never shadow an existing named route (SPA route or API namespace). The
// catch-all page route is matched only after these, but reserving the names up
// front gives the admin a clear "that slug is reserved" error at create time
// instead of a silently unreachable page.
//
// Kept as a Set of lowercase single-segment slugs. Page slugs are validated to a
// single segment (^[a-z0-9-]+$) so we only need to guard first path segments.
const RESERVED_SLUGS = new Set([
// API / infrastructure
'api', 'internal', 'uploads', 'assets', 'static', 'public',
// Auth / account
'login', 'logout', 'register', 'account', 'auth',
// Admin app
'admin',
// Existing top-level SPA sections
'site', 'wiki', 'news', 'newsletter', 'screenshots', 'five-on-friday', 'about', 'status',
// Page-builder's own surface
'pages', 'preview',
])
/** @returns {boolean} true if `slug` collides with a reserved route name. */
function isReservedSlug(slug) {
return RESERVED_SLUGS.has(String(slug).toLowerCase())
}
module.exports = { RESERVED_SLUGS, isReservedSlug }

View File

@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller') const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller') const emailConfig = require('./emailConfig.controller')
const moderation = require('./moderation.controller') const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth') const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex') const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
@@ -475,6 +476,99 @@ adminRouter.delete(
ctrl.deleteWiki, ctrl.deleteWiki,
) )
// ── CMS Pages (block-based page builder) ──────────────────────────────
adminRouter.get(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'List all CMS pages (summaries)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
pagesCtrl.listPages,
)
adminRouter.post(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Create a CMS page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('slug').isString().trim().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
validate,
pagesCtrl.createPage,
)
adminRouter.get(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.getPage,
)
adminRouter.patch(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.updatePage,
)
adminRouter.delete(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.deletePage,
)
adminRouter.post(
'/pages/:id/unprotect',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('password').isString().notEmpty(),
validate,
pagesCtrl.unprotectPage,
)
adminRouter.post(
'/pages/:id/preview',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.createPreview,
)
// ── Settings ────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────
adminRouter.get( adminRouter.get(
'/settings', '/settings',

View File

@@ -0,0 +1,131 @@
// Admin CMS pages controller. Thin HTTP layer over pages.model — it translates
// the model's PageError (status + code) into responses and records audit-log
// entries for the lifecycle events the spec calls out (create / publish /
// unpublish / delete, protect on, and the password-gated unprotect).
const pages = require('../../../model/pages/pages.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const token = require('../../../auth/token')
const logger = require('../../../utils/logger')('pages')
// Map a thrown error to a response. Known PageErrors carry a status + code (and
// sometimes a block-error list); anything else is an unexpected 500.
function fail(res, err) {
if (err && err.name === 'PageError') {
const body = { error: err.message, code: err.code }
if (err.errors) body.details = err.errors
return res.status(err.status).json(body)
}
logger.error('unexpected pages error', { error: err.message })
return res.status(500).json({ error: 'Internal error' })
}
async function listPages(req, res) {
return res.json(await pages.list())
}
async function getPage(req, res) {
const page = await pages.getById(Number(req.params.id))
if (!page) return res.status(404).json({ error: 'Page not found', code: 'not_found' })
return res.json(page)
}
async function createPage(req, res) {
try {
const page = await pages.create(req.body, req.user.id)
await activity.log({ req, action: 'page.create', detail: { id: page.id, slug: page.slug } })
if (page.status === 'published') {
await activity.log({ req, action: 'page.publish', detail: { id: page.id, slug: page.slug } })
}
return res.status(201).json(page)
} catch (err) {
return fail(res, err)
}
}
async function updatePage(req, res) {
try {
const id = Number(req.params.id)
const before = await pages.getRawById(id)
if (!before) return res.status(404).json({ error: 'Page not found', code: 'not_found' })
const page = await pages.update(id, req.body)
await activity.log({ req, action: 'page.update', detail: { id, slug: page.slug } })
// Emit dedicated audit events for the transitions the spec singles out.
if (before.status !== page.status) {
const action = page.status === 'published' ? 'page.publish' : 'page.unpublish'
await activity.log({ req, action, detail: { id, slug: page.slug } })
}
if (!before.protected && page.settings.protected) {
await activity.log({ req, action: 'page.protect', detail: { id, slug: page.slug } })
}
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
async function deletePage(req, res) {
try {
const id = Number(req.params.id)
const result = await pages.remove(id)
await activity.log({ req, action: 'page.delete', detail: { id } })
return res.json(result)
} catch (err) {
return fail(res, err)
}
}
// Step-up auth: verify the CURRENT admin's password against their own hash
// (independent of JWT validity) before flipping protected → false. On failure:
// no mutation, standard 401, and the entered password is never logged anywhere.
async function unprotectPage(req, res) {
try {
const id = Number(req.params.id)
const password = req.body?.password
if (typeof password !== 'string' || password === '') {
return res.status(400).json({ error: 'Password is required', code: 'password_required' })
}
const user = await users.getRawById(req.user.id)
const ok = await users.validatePassword(user, password)
if (!ok) {
logger.warn('failed page unprotect (bad password)', { pageId: id, userId: req.user.id })
return res.status(401).json({ error: 'Password is incorrect', code: 'bad_password' })
}
const page = await pages.unprotect(id)
await activity.log({ req, action: 'page.unprotect', detail: { id, slug: page.slug } })
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
// Mint a 1h preview token for the page's current (possibly unpublished) state.
// Returns the token plus the ready-to-use public preview path.
async function createPreview(req, res) {
try {
const id = Number(req.params.id)
const page = await pages.getById(id)
if (!page) return res.status(404).json({ error: 'Page not found', code: 'not_found' })
const t = token.signPagePreview(id)
return res.json({
token: t,
expiresInSeconds: 3600,
path: `/api/v1/public/pages/${id}/preview/${t}`,
})
} catch (err) {
return fail(res, err)
}
}
module.exports = {
listPages,
getPage,
createPage,
updatePage,
deletePage,
unprotectPage,
createPreview,
}

View File

@@ -1,10 +1,21 @@
const posts = require('../../../model/posts/posts.model') const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model') const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model') const settings = require('../../../model/settings/settings.model')
const pages = require('../../../model/pages/pages.model')
const mailer = require('../../../utils/mailer') const mailer = require('../../../utils/mailer')
const { getUserFromRequest } = require('../../../utils/auth')
const token = require('../../../auth/token')
const log = require('../../../utils/logger')('public') const log = require('../../../utils/logger')('public')
// Staff (non-player) roles may see draft pages on the public route; everyone else
// gets a 404 for a draft, indistinguishable from a missing page.
const STAFF_ROLES = ['admin', 'editor', 'moderator']
function isStaff(req) {
const user = getUserFromRequest(req)
return Boolean(user && STAFF_ROLES.includes(user.role))
}
async function getSettings(req, res) { async function getSettings(req, res) {
try { try {
return res.json(await settings.getPublic()) return res.json(await settings.getPublic())
@@ -100,6 +111,34 @@ async function getWikiPage(req, res) {
} }
} }
async function getPage(req, res) {
try {
// Staff see drafts (live preview); the public sees published pages only.
const page = await pages.getBySlug(req.params.slug, { includeUnpublished: isStaff(req) })
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Token-gated draft preview: renders the page's current block state regardless of
// status, for anyone holding the (short-lived, unguessable) link.
async function getPagePreview(req, res) {
try {
const id = Number(req.params.id)
const decoded = token.verifyPagePreview(req.params.token)
if (!decoded || decoded.pageId !== id) {
return res.status(404).json({ message: 'Preview not found or expired' })
}
const page = await pages.getById(id)
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function contact(req, res) { async function contact(req, res) {
const { name, email, message } = req.body const { name, email, message } = req.body
try { try {
@@ -120,5 +159,7 @@ module.exports = {
getWikiTags, getWikiTags,
getWikiList, getWikiList,
getWikiPage, getWikiPage,
getPage,
getPagePreview,
contact, contact,
} }

View File

@@ -102,4 +102,30 @@ publicRouter.get(
ctrl.getWikiPage, ctrl.getWikiPage,
) )
// ── CMS pages (block-based) ────────────────────────────────────────────
// Preview is registered before /pages/:slug and is NOT site-mode gated, so a
// draft-preview link keeps working during maintenance. The token itself is the
// access control.
publicRouter.get(
'/pages/:id/preview/:token',
// #swagger.tags = ['Public']
// #swagger.summary = 'Render a page from a draft-preview token'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Preview token from POST /admin/pages/:id/preview.' }
/* #swagger.responses[200] = { description: 'The page (any status)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Token invalid/expired or page missing', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.getPagePreview,
)
publicRouter.get(
'/pages/:slug',
// #swagger.tags = ['Public']
// #swagger.summary = 'Get a published CMS page by slug'
// #swagger.description = 'Drafts 404 for the public; staff sessions see drafts. Gated by site mode.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page slug.' }
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getPage,
)
module.exports = publicRouter module.exports = publicRouter