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