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:
@@ -66,6 +66,20 @@ function verifyTotpChallenge(token) {
|
||||
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).
|
||||
function cookieMaxAge() {
|
||||
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
|
||||
@@ -122,6 +136,8 @@ module.exports = {
|
||||
verifyToken,
|
||||
signTotpChallenge,
|
||||
verifyTotpChallenge,
|
||||
signPagePreview,
|
||||
verifyPagePreview,
|
||||
cookieMaxAge,
|
||||
cookieSecure,
|
||||
cookieOptions,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
const registry = require('./registry')
|
||||
const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks')
|
||||
const { sanitizeBlocks } = require('./sanitizeBlocks')
|
||||
|
||||
// ── Wave 1 block definitions (self-register on require) ────────────────
|
||||
require('./types/heading')
|
||||
@@ -23,6 +24,7 @@ require('./types/quote')
|
||||
module.exports = {
|
||||
...registry,
|
||||
validateBlocks,
|
||||
sanitizeBlocks,
|
||||
MAX_BLOCKS,
|
||||
MAX_SUBBLOCKS,
|
||||
}
|
||||
|
||||
47
server/src/blocks/sanitizeBlocks.js
Normal file
47
server/src/blocks/sanitizeBlocks.js
Normal 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 }
|
||||
79
server/src/model/pages/pages.db.js
Normal file
79
server/src/model/pages/pages.db.js
Normal 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 }
|
||||
306
server/src/model/pages/pages.model.js
Normal file
306
server/src/model/pages/pages.model.js
Normal 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,
|
||||
}
|
||||
28
server/src/model/pages/reservedSlugs.js
Normal file
28
server/src/model/pages/reservedSlugs.js
Normal 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 }
|
||||
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
|
||||
const discordBot = require('./discordBot.controller')
|
||||
const emailConfig = require('./emailConfig.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
const pagesCtrl = require('./pages.controller')
|
||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -475,6 +476,99 @@ adminRouter.delete(
|
||||
ctrl.deleteWiki,
|
||||
)
|
||||
|
||||
// ── CMS Pages (block-based page builder) ──────────────────────────────
|
||||
adminRouter.get(
|
||||
'/pages',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'List all CMS pages (summaries)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
pagesCtrl.listPages,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/pages',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Create a CMS page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('slug').isString().trim().notEmpty(),
|
||||
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
|
||||
validate,
|
||||
pagesCtrl.createPage,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/pages/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.getPage,
|
||||
)
|
||||
adminRouter.patch(
|
||||
'/pages/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
|
||||
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.updatePage,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/pages/:id',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.deletePage,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/pages/:id/unprotect',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
|
||||
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('password').isString().notEmpty(),
|
||||
validate,
|
||||
pagesCtrl.unprotectPage,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/pages/:id/preview',
|
||||
// #swagger.tags = ['Admin · Pages']
|
||||
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
|
||||
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
pagesCtrl.createPreview,
|
||||
)
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────
|
||||
adminRouter.get(
|
||||
'/settings',
|
||||
|
||||
131
server/src/router/v1/admin/pages.controller.js
Normal file
131
server/src/router/v1/admin/pages.controller.js
Normal 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,
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
const posts = require('../../../model/posts/posts.model')
|
||||
const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const pages = require('../../../model/pages/pages.model')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
const { getUserFromRequest } = require('../../../utils/auth')
|
||||
const token = require('../../../auth/token')
|
||||
|
||||
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) {
|
||||
try {
|
||||
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) {
|
||||
const { name, email, message } = req.body
|
||||
try {
|
||||
@@ -120,5 +159,7 @@ module.exports = {
|
||||
getWikiTags,
|
||||
getWikiList,
|
||||
getWikiPage,
|
||||
getPage,
|
||||
getPagePreview,
|
||||
contact,
|
||||
}
|
||||
|
||||
@@ -102,4 +102,30 @@ publicRouter.get(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user