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>
307 lines
10 KiB
JavaScript
307 lines
10 KiB
JavaScript
// 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,
|
|
}
|