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