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:
@@ -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 <h2> for the table of contents)</span>
|
||||
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
|
||||
|
||||
Reference in New Issue
Block a user