Wiki Phase 1: categories, drafts/publish, HTML sanitization

Foundation & safety phase of the wiki upgrade (see WIKI_UPGRADE.md).

Schema (additive, idempotent via ensureSchema):
- new wiki_categories table; wiki_pages gains category_id, excerpt,
  published, published_at, sort_order, and a FULLTEXT index
- migration ALTERs guarded with IF NOT EXISTS for existing databases
- seed reworked into 4 sections with the 8 starter pages assigned

Security:
- new utils/sanitizeHtml.js (sanitize-html allowlist); wiki bodies are
  sanitized on every save, and the article renders through DOMPurify
- strips <script>, event handlers (onerror), and javascript: URLs

Backend:
- public: published-only list with ?category filter + /wiki/categories
- admin: extended page CRUD, PATCH publish toggle, category CRUD;
  drafts visible to admin, hidden from public
- all writes logged to activity_log

Frontend:
- data-driven public wiki index (sections + real descriptions; removed
  hardcoded blurbs/Roman numerals) with ?category filtering
- article: category breadcrumb + sanitized render
- admin: Section/Status columns, draft/publish + section + excerpt in the
  editor, and a Manage sections modal

Verified end-to-end against MariaDB 11: migration clean, XSS neutralized,
drafts hidden, client builds, server boots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 10:45:21 -05:00
parent dd1f61222d
commit b925114923
20 changed files with 1237 additions and 100 deletions

View File

@@ -1,33 +1,157 @@
const { query } = require('../../utils/db')
async function listSummaries() {
return query('SELECT slug, title, updated_at FROM wiki_pages ORDER BY title ASC')
// Full page row + joined category fields.
const PAGE_COLS =
'p.id, p.slug, p.title, p.body, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_by, p.created_at, p.updated_at, p.published_at, ' +
'c.slug AS category_slug, c.title AS category_title'
// List rows omit the body (lighter payload for indexes/tables).
const SUMMARY_COLS =
'p.id, p.slug, p.title, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_at, p.published_at, c.slug AS category_slug, c.title AS category_title'
const FROM = 'FROM wiki_pages p LEFT JOIN wiki_categories c ON c.id = p.category_id'
const ORDER = 'ORDER BY p.sort_order ASC, p.title ASC'
// ── Page reads ─────────────────────────────────────────────────────────
// Published summaries (public). Optional category filter by id.
async function listPublishedSummaries(categoryId = null) {
if (categoryId != null) {
return query(
`SELECT ${SUMMARY_COLS} ${FROM} WHERE p.published = 1 AND p.category_id = ? ${ORDER}`,
[categoryId],
)
}
return query(`SELECT ${SUMMARY_COLS} ${FROM} WHERE p.published = 1 ${ORDER}`)
}
// All summaries (admin), with optional category / status filters.
async function listAllSummaries({ categoryId = null, published = null } = {}) {
const where = []
const params = []
if (categoryId != null) {
where.push('p.category_id = ?')
params.push(categoryId)
}
if (published != null) {
where.push('p.published = ?')
params.push(published ? 1 : 0)
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
return query(`SELECT ${SUMMARY_COLS} ${FROM} ${clause} ${ORDER}`, params)
}
async function findBySlug(slug) {
const rows = await query('SELECT * FROM wiki_pages WHERE slug = ? LIMIT 1', [slug])
const rows = await query(`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function insert({ slug, title, body, updatedBy = null }) {
async function findPublishedBySlug(slug) {
const rows = await query(
`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? AND p.published = 1 LIMIT 1`,
[slug],
)
return rows[0] || null
}
// ── Page writes ────────────────────────────────────────────────────────
async function insert({
slug,
title,
body = null,
excerpt = null,
categoryId = null,
published = true,
sortOrder = 0,
updatedBy = null,
}) {
const res = await query(
'INSERT INTO wiki_pages (slug, title, body, updated_by) VALUES (?, ?, ?, ?)',
[slug, title, body || null, updatedBy],
'INSERT INTO wiki_pages (slug, title, body, excerpt, category_id, published, sort_order, published_at, updated_by) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
slug,
title,
body,
excerpt,
categoryId,
published ? 1 : 0,
sortOrder,
published ? new Date() : null,
updatedBy,
],
)
return res.insertId
}
async function updateBySlug(slug, { title, body, updatedBy = null }) {
await query(
'UPDATE wiki_pages SET title = ?, body = ?, updated_by = ? WHERE slug = ?',
[title, body || null, updatedBy, slug],
)
// Dynamic update — only the provided columns are written.
async function updateBySlug(slug, 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(slug)
await query(`UPDATE wiki_pages SET ${cols.join(', ')} WHERE slug = ?`, params)
}
async function deleteBySlug(slug) {
return query('DELETE FROM wiki_pages WHERE slug = ?', [slug])
}
// ── Categories ─────────────────────────────────────────────────────────
const CAT_COLS = 'id, slug, title, description, sort_order, created_at, updated_at'
// Categories with page counts (total + published) for index/admin views.
async function listCategories() {
return query(
`SELECT c.id, c.slug, c.title, c.description, c.sort_order, c.created_at, c.updated_at,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id) AS page_count,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id AND p.published = 1) AS published_count
FROM wiki_categories c
ORDER BY c.sort_order ASC, c.title ASC`,
)
}
async function findCategoryBySlug(slug) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function findCategoryById(id) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertCategory({ slug, title, description = null, sortOrder = 0 }) {
const res = await query(
'INSERT INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description, sortOrder],
)
return res.insertId
}
async function updateCategory(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 wiki_categories SET ${cols.join(', ')} WHERE id = ?`, params)
}
// Detach pages first (works even on upgraded DBs that lack the FK), then delete.
async function deleteCategory(id) {
await query('UPDATE wiki_pages SET category_id = NULL WHERE category_id = ?', [id])
return query('DELETE FROM wiki_categories WHERE id = ?', [id])
}
// ── Seeding (idempotent) ───────────────────────────────────────────────
async function seedDefault(slug, title, body) {
await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [
slug,
@@ -36,11 +160,38 @@ async function seedDefault(slug, title, body) {
])
}
async function seedDefaultCategory(slug, title, description, sortOrder = 0) {
await query(
'INSERT IGNORE INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description || null, sortOrder],
)
}
// Assign a seeded page to a category by slug, only if not already categorized —
// migrates pre-upgrade pages without clobbering manual changes.
async function assignCategoryBySlug(pageSlug, categorySlug) {
await query(
'UPDATE wiki_pages SET category_id = (SELECT id FROM wiki_categories WHERE slug = ?) ' +
'WHERE slug = ? AND category_id IS NULL',
[categorySlug, pageSlug],
)
}
module.exports = {
listSummaries,
listPublishedSummaries,
listAllSummaries,
findBySlug,
findPublishedBySlug,
insert,
updateBySlug,
deleteBySlug,
listCategories,
findCategoryBySlug,
findCategoryById,
insertCategory,
updateCategory,
deleteCategory,
seedDefault,
seedDefaultCategory,
assignCategoryBySlug,
}

View File

@@ -1,20 +1,62 @@
const wikiDb = require('./wiki.db')
const { cleanBody } = require('../../utils/sanitizeHtml')
async function list() {
return wikiDb.listSummaries()
// ── Pages ──────────────────────────────────────────────────────────────
async function listPublished(categoryId = null) {
return wikiDb.listPublishedSummaries(categoryId)
}
async function listAll(filters = {}) {
return wikiDb.listAllSummaries(filters)
}
async function getBySlug(slug) {
return wikiDb.findBySlug(slug)
}
async function create({ slug, title, body, updatedBy }) {
await wikiDb.insert({ slug, title, body, updatedBy })
async function getPublishedBySlug(slug) {
return wikiDb.findPublishedBySlug(slug)
}
async function create({ slug, title, body, excerpt, categoryId, published, updatedBy }) {
await wikiDb.insert({
slug,
title,
body: cleanBody(body),
excerpt: excerpt || null,
categoryId: categoryId ?? null,
published: published !== false, // default published unless explicitly false
updatedBy,
})
return wikiDb.findBySlug(slug)
}
async function update(slug, { title, body, updatedBy }) {
await wikiDb.updateBySlug(slug, { title, body, updatedBy })
// Partial update — only keys present in `input` are written. Body is sanitized;
// published_at is stamped the first time a page goes live.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { updated_by: input.updatedBy ?? null }
if ('title' in input) fields.title = input.title
if ('body' in input) fields.body = cleanBody(input.body)
if ('excerpt' in input) fields.excerpt = input.excerpt || null
if ('categoryId' in input) fields.category_id = input.categoryId ?? null
if ('published' in input) {
fields.published = input.published ? 1 : 0
if (input.published && !current.published_at) fields.published_at = new Date()
}
await wikiDb.updateBySlug(slug, fields)
return wikiDb.findBySlug(slug)
}
async function setPublished(slug, published) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { published: published ? 1 : 0 }
if (published && !current.published_at) fields.published_at = new Date()
await wikiDb.updateBySlug(slug, fields)
return wikiDb.findBySlug(slug)
}
@@ -22,4 +64,52 @@ async function remove(slug) {
return wikiDb.deleteBySlug(slug)
}
module.exports = { list, getBySlug, create, update, remove }
// ── Categories ─────────────────────────────────────────────────────────
async function listCategories() {
return wikiDb.listCategories()
}
async function getCategoryBySlug(slug) {
return wikiDb.findCategoryBySlug(slug)
}
async function getCategoryById(id) {
return wikiDb.findCategoryById(id)
}
async function createCategory({ slug, title, description, sortOrder }) {
const id = await wikiDb.insertCategory({ slug, title, description, sortOrder })
return wikiDb.findCategoryById(id)
}
async function updateCategory(id, input) {
const fields = {}
if ('title' in input) fields.title = input.title
if ('slug' in input) fields.slug = input.slug
if ('description' in input) fields.description = input.description || null
if ('sortOrder' in input) fields.sort_order = input.sortOrder
await wikiDb.updateCategory(id, fields)
return wikiDb.findCategoryById(id)
}
async function removeCategory(id) {
return wikiDb.deleteCategory(id)
}
module.exports = {
list: listPublished, // back-compat alias (old callers expected published list)
listPublished,
listAll,
getBySlug,
getPublishedBySlug,
create,
update,
setPublished,
remove,
listCategories,
getCategoryBySlug,
getCategoryById,
createCategory,
updateCategory,
removeCategory,
}

View File

@@ -160,10 +160,17 @@ async function uploadImage(req, res) {
return res.status(201).json({ image_url: imageUrl })
}
// ── Wiki ──────────────────────────────────────────────────────────────
// ── Wiki pages ─────────────────────────────────────────────────────────
async function listWiki(req, res) {
try {
return res.json(await wiki.list())
const filters = {}
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
filters.categoryId = category ? category.id : -1 // unknown → match nothing
}
if (req.query.status === 'draft') filters.published = false
if (req.query.status === 'published') filters.published = true
return res.json(await wiki.listAll(filters))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
@@ -179,15 +186,32 @@ async function getWiki(req, res) {
}
}
// Resolve a category_id from the request, validating it exists. Returns
// { ok, value } so the caller can distinguish "not provided" from "invalid".
async function resolveCategoryId(body) {
if (!('category_id' in body) || body.category_id == null || body.category_id === '') {
return { ok: true, value: null }
}
const category = await wiki.getCategoryById(Number(body.category_id))
if (!category) return { ok: false }
return { ok: true, value: category.id }
}
async function createWiki(req, res) {
try {
if (await wiki.getBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A page with that slug already exists' })
}
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
const page = await wiki.create({
slug: req.body.slug,
title: req.body.title,
body: req.body.body || null,
excerpt: req.body.excerpt || null,
categoryId: cat.value,
published: req.body.published !== false,
updatedBy: req.user.id,
})
await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } })
@@ -202,11 +226,19 @@ async function updateWiki(req, res) {
try {
const existing = await wiki.getBySlug(req.params.slug)
if (!existing) return res.status(404).json({ message: 'Not found' })
const page = await wiki.update(req.params.slug, {
title: req.body.title,
body: req.body.body || null,
updatedBy: req.user.id,
})
const input = { updatedBy: req.user.id }
if ('title' in req.body) input.title = req.body.title
if ('body' in req.body) input.body = req.body.body || null
if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null
if ('published' in req.body) input.published = Boolean(req.body.published)
if ('category_id' in req.body) {
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
input.categoryId = cat.value
}
const page = await wiki.update(req.params.slug, input)
await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } })
return res.json(page)
} catch (err) {
@@ -215,6 +247,22 @@ async function updateWiki(req, res) {
}
}
async function publishWiki(req, res) {
try {
const page = await wiki.setPublished(req.params.slug, Boolean(req.body.published))
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.publish',
detail: { slug: req.params.slug, published: Boolean(req.body.published) },
})
return res.json(page)
} catch (err) {
log.error('publishWiki', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWiki(req, res) {
try {
await wiki.remove(req.params.slug)
@@ -225,6 +273,73 @@ async function deleteWiki(req, res) {
}
}
// ── Wiki categories ────────────────────────────────────────────────────
async function listWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function createWikiCategory(req, res) {
try {
if (await wiki.getCategoryBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A category with that slug already exists' })
}
const category = await wiki.createCategory({
slug: req.body.slug,
title: req.body.title,
description: req.body.description || null,
sortOrder: Number(req.body.sort_order) || 0,
})
await activity.log({ req, action: 'wiki.category.create', detail: { slug: category.slug } })
return res.status(201).json(category)
} catch (err) {
log.error('createWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
const input = {}
if ('title' in req.body) input.title = req.body.title
if ('description' in req.body) input.description = req.body.description || null
if ('sort_order' in req.body) input.sortOrder = Number(req.body.sort_order) || 0
if ('slug' in req.body && req.body.slug !== existing.slug) {
const clash = await wiki.getCategoryBySlug(req.body.slug)
if (clash) return res.status(409).json({ message: 'A category with that slug already exists' })
input.slug = req.body.slug
}
const category = await wiki.updateCategory(id, input)
await activity.log({ req, action: 'wiki.category.update', detail: { id } })
return res.json(category)
} catch (err) {
log.error('updateWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
await wiki.removeCategory(id) // pages in it become uncategorized
await activity.log({ req, action: 'wiki.category.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Settings ──────────────────────────────────────────────────────────
async function getSettings(req, res) {
try {
@@ -346,7 +461,12 @@ module.exports = {
getWiki,
createWiki,
updateWiki,
publishWiki,
deleteWiki,
listWikiCategories,
createWikiCategory,
updateWikiCategory,
deleteWikiCategory,
getSettings,
updateSettings,
listActivity,

View File

@@ -65,22 +65,57 @@ adminRouter.patch(
)
adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost)
// ── Wiki ──────────────────────────────────────────────────────────────
// ── Wiki categories (static paths registered before /wiki/:slug) ───────
adminRouter.get('/wiki/categories', ctrl.listWikiCategories)
adminRouter.post(
'/wiki/categories',
body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.createWikiCategory,
)
adminRouter.put(
'/wiki/categories/:id',
param('id').isInt(),
body('slug').optional().matches(/^[a-z0-9-]+$/),
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.updateWikiCategory,
)
adminRouter.delete('/wiki/categories/:id', param('id').isInt(), validate, ctrl.deleteWikiCategory)
// ── Wiki pages ─────────────────────────────────────────────────────────
adminRouter.get('/wiki', ctrl.listWiki)
adminRouter.post(
'/wiki',
body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
validate,
ctrl.createWiki,
)
adminRouter.get('/wiki/:slug', ctrl.getWiki)
adminRouter.put(
'/wiki/:slug',
body('title').isString().trim().notEmpty(),
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
validate,
ctrl.updateWiki,
)
adminRouter.patch(
'/wiki/:slug/publish',
body('published').isBoolean(),
validate,
ctrl.publishWiki,
)
adminRouter.delete('/wiki/:slug', ctrl.deleteWiki)
// ── Settings ──────────────────────────────────────────────────────────

View File

@@ -50,9 +50,23 @@ async function getPost(req, res) {
}
}
async function getWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiList(req, res) {
try {
return res.json(await wiki.list())
let categoryId = null
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
if (!category) return res.json([]) // unknown category → no pages
categoryId = category.id
}
return res.json(await wiki.listPublished(categoryId))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
@@ -60,7 +74,8 @@ async function getWikiList(req, res) {
async function getWikiPage(req, res) {
try {
const page = await wiki.getBySlug(req.params.slug)
// Public sees published pages only; drafts 404 like any missing page.
const page = await wiki.getPublishedBySlug(req.params.slug)
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
@@ -84,6 +99,7 @@ module.exports = {
getStatus,
getPosts,
getPost,
getWikiCategories,
getWikiList,
getWikiPage,
contact,

View File

@@ -25,6 +25,8 @@ publicRouter.post(
publicRouter.get('/posts/:category', siteMode, ctrl.getPosts)
publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost)
publicRouter.get('/wiki', siteMode, ctrl.getWikiList)
// Static path must precede the :slug route so it isn't captured as a slug.
publicRouter.get('/wiki/categories', siteMode, ctrl.getWikiCategories)
publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage)
module.exports = publicRouter

View File

@@ -0,0 +1,45 @@
const sanitizeHtml = require('sanitize-html')
// Allowlist for wiki/post body HTML. Anything not listed is stripped. This runs
// on every save so the stored value is already safe; the client re-sanitizes on
// render as defense in depth. Tuned for rich-text content from the admin editor.
const OPTIONS = {
allowedTags: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
'a', 'img', 'figure', 'figcaption',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
},
// http/https for links and images, mailto for links, plus relative URLs so
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: { img: ['http', 'https'] },
allowProtocolRelative: false,
// Force safe rel on links that open a new tab; drop empty/odd attributes.
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
},
disallowedTagsMode: 'discard',
}
/**
* Sanitize a block of body HTML against the allowlist above.
* Null/empty input is returned unchanged.
* @param {string|null|undefined} html
* @returns {string|null|undefined}
*/
function cleanBody(html) {
if (html == null || html === '') return html
return sanitizeHtml(String(html), OPTIONS)
}
module.exports = { cleanBody, OPTIONS }