diff --git a/server/src/auth/token.js b/server/src/auth/token.js index d88efc3..fd4263d 100644 --- a/server/src/auth/token.js +++ b/server/src/auth/token.js @@ -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, diff --git a/server/src/blocks/index.js b/server/src/blocks/index.js index daf35e1..10ecaa6 100644 --- a/server/src/blocks/index.js +++ b/server/src/blocks/index.js @@ -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, } diff --git a/server/src/blocks/sanitizeBlocks.js b/server/src/blocks/sanitizeBlocks.js new file mode 100644 index 0000000..c9d91d6 --- /dev/null +++ b/server/src/blocks/sanitizeBlocks.js @@ -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 } diff --git a/server/src/model/pages/pages.db.js b/server/src/model/pages/pages.db.js new file mode 100644 index 0000000..fb2ec3b --- /dev/null +++ b/server/src/model/pages/pages.db.js @@ -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 } diff --git a/server/src/model/pages/pages.model.js b/server/src/model/pages/pages.model.js new file mode 100644 index 0000000..8e6130e --- /dev/null +++ b/server/src/model/pages/pages.model.js @@ -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, +} diff --git a/server/src/model/pages/reservedSlugs.js b/server/src/model/pages/reservedSlugs.js new file mode 100644 index 0000000..b515b2e --- /dev/null +++ b/server/src/model/pages/reservedSlugs.js @@ -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 } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 1d9dd53..95d9f6a 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -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', diff --git a/server/src/router/v1/admin/pages.controller.js b/server/src/router/v1/admin/pages.controller.js new file mode 100644 index 0000000..a29ae0d --- /dev/null +++ b/server/src/router/v1/admin/pages.controller.js @@ -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, +} diff --git a/server/src/router/v1/public/public.controller.js b/server/src/router/v1/public/public.controller.js index 2559171..041a423 100644 --- a/server/src/router/v1/public/public.controller.js +++ b/server/src/router/v1/public/public.controller.js @@ -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, } diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 80927dd..36103a0 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -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