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

@@ -4,17 +4,48 @@ import { api } from '../../../api/client.js'
export default function WikiEditor({ slug, onClose, onSaved }) {
const isEdit = Boolean(slug)
const [form, setForm] = useState({ slug: '', title: '', body: '' })
const [form, setForm] = useState({
slug: '',
title: '',
body: '',
excerpt: '',
category_id: '',
published: true,
})
const [categories, setCategories] = useState([])
const [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
// Categories for the dropdown (both new and edit).
useEffect(() => {
let active = true
api.admin
.listWikiCategories()
.then((cats) => active && setCategories(cats))
.catch(() => {})
return () => {
active = false
}
}, [])
useEffect(() => {
if (!isEdit) return
let active = true
api.admin
.getWiki(slug)
.then((p) => active && setForm({ slug: p.slug, title: p.title, body: p.body || '' }))
.then(
(p) =>
active &&
setForm({
slug: p.slug,
title: p.title,
body: p.body || '',
excerpt: p.excerpt || '',
category_id: p.category_id != null ? String(p.category_id) : '',
published: Boolean(p.published),
}),
)
.catch(() => active && setError('Could not load this page.'))
.finally(() => active && setLoading(false))
return () => {
@@ -24,14 +55,26 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
function payload() {
return {
title: form.title.trim(),
body: form.body,
excerpt: form.excerpt.trim(),
category_id: form.category_id ? Number(form.category_id) : null,
published: form.published,
}
}
async function save() {
if (!form.title.trim()) return setError('Title is required.')
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) return setError('Slug must be lowercase letters, numbers, and dashes.')
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) {
return setError('Slug must be lowercase letters, numbers, and dashes.')
}
setBusy(true)
setError('')
try {
if (isEdit) await api.admin.updateWiki(slug, { title: form.title.trim(), body: form.body })
else await api.admin.createWiki({ slug: form.slug, title: form.title.trim(), body: form.body })
if (isEdit) await api.admin.updateWiki(slug, payload())
else await api.admin.createWiki({ slug: form.slug, ...payload() })
onSaved()
} catch (err) {
setError(err.message || 'Could not save.')
@@ -67,7 +110,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
Cancel
</button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save'}
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
</button>
</>
}
@@ -93,6 +136,40 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
<span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" />
</label>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 200px' }}>
<span className="field-label">Section</span>
<select value={form.category_id} onChange={set('category_id')} className="input">
<option value=""> Uncategorized </option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</label>
<label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 10 }}>
<input
type="checkbox"
checked={form.published}
onChange={(e) => setForm((f) => ({ ...f, published: e.target.checked }))}
/>
<span className="field-label" style={{ margin: 0 }}>
Published
</span>
</label>
</div>
<label>
<span className="field-label">Excerpt (card teaser on the wiki index)</span>
<input
type="text"
value={form.excerpt}
onChange={set('excerpt')}
className="input"
maxLength={400}
placeholder="One-line summary shown on the wiki home."
/>
</label>
<label>
<span className="field-label">Body (HTML use &lt;h2&gt; for the table of contents)</span>
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />