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