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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user