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

@@ -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