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,5 +1,6 @@
import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
@@ -13,11 +14,13 @@ function slugify(text) {
.replace(/(^-|-$)/g, '')
}
// Parse the stored body HTML: assign ids to <h2> headings and collect a TOC.
// Parse the stored body HTML: sanitize (defense in depth — the server also
// sanitizes on save), then assign ids to <h2> headings and collect a TOC.
function buildArticle(body) {
if (!body) return { html: '', toc: [] }
if (typeof window === 'undefined' || !window.DOMParser) return { html: body, toc: [] }
const doc = new DOMParser().parseFromString(body, 'text/html')
if (typeof window === 'undefined' || !window.DOMParser) return { html: '', toc: [] }
const safe = DOMPurify.sanitize(body)
const doc = new DOMParser().parseFromString(safe, 'text/html')
const toc = []
doc.querySelectorAll('h2').forEach((h, i) => {
const id = slugify(h.textContent || '') || `section-${i}`
@@ -77,6 +80,17 @@ export default function WikiArticle() {
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Wiki
</Link>
{page.category_title && (
<>
<span>/</span>
<Link
to={`/wiki?category=${page.category_slug}`}
style={{ color: 'var(--accent)', textDecoration: 'none' }}
>
{page.category_title}
</Link>
</>
)}
<span>/</span>
<span>{page.title}</span>
</p>