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