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

@@ -49,7 +49,8 @@ export const api = {
status: () => req('/public/status'),
posts: (category) => req(`/public/posts/${category}`),
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
wiki: () => req('/public/wiki'),
wiki: (category) => req(`/public/wiki${category ? `?category=${encodeURIComponent(category)}` : ''}`),
wikiCategories: () => req('/public/wiki/categories'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
@@ -69,11 +70,18 @@ export const api = {
fd.append('image', file)
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
},
listWiki: () => req('/admin/wiki'),
listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
publishWiki: (slug, published) =>
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
listWikiCategories: () => req('/admin/wiki/categories'),
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
updateWikiCategory: (id, data) =>
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),

View File

@@ -4,12 +4,14 @@ import { useAsync } from '../../../lib/useAsync.js'
import { shortDate } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import WikiEditor from './WikiEditor.jsx'
import WikiCategories from './WikiCategories.jsx'
export default function WikiAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick])
const [editing, setEditing] = useState(null) // null | 'new' | slug
const [managingCats, setManagingCats] = useState(false)
const pages = data || []
return (
@@ -18,9 +20,14 @@ export default function WikiAdmin() {
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
</p>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New page
</button>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={() => setManagingCats(true)} className="pill">
Manage sections
</button>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New page
</button>
</div>
</div>
{loading && <Loading />}
@@ -32,7 +39,8 @@ export default function WikiAdmin() {
<thead>
<tr>
<th className="adm-th">Page</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Section</th>
<th className="adm-th">Status</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
@@ -42,9 +50,15 @@ export default function WikiAdmin() {
<tr key={w.slug}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{w.title}
<span
style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
>
{w.slug}
</span>
</td>
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
{w.slug}
<td className="adm-td dim">{w.category_title || '—'}</td>
<td className="adm-td">
<StatusPill published={w.published} />
</td>
<td className="adm-td dim">{shortDate(w.updated_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
@@ -54,6 +68,13 @@ export default function WikiAdmin() {
</td>
</tr>
))}
{pages.length === 0 && (
<tr>
<td className="adm-td dim" colSpan={5}>
No wiki pages yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
@@ -69,6 +90,37 @@ export default function WikiAdmin() {
}}
/>
)}
{managingCats && (
<WikiCategories
onClose={() => {
setManagingCats(false)
reload() // section titles may have changed
}}
/>
)}
</section>
)
}
function StatusPill({ published }) {
const live = Boolean(published)
return (
<span
className="sans"
style={{
fontSize: '0.72rem',
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
padding: '2px 9px',
borderRadius: 999,
border: `1px solid ${live ? 'rgba(108,176,140,0.5)' : 'var(--line)'}`,
color: live ? '#8fc7a6' : 'var(--dim)',
background: live ? 'rgba(108,176,140,0.12)' : 'transparent',
}}
>
{live ? 'Published' : 'Draft'}
</span>
)
}

View File

@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { api } from '../../../api/client.js'
const EMPTY = { slug: '', title: '', description: '', sort_order: 0 }
export default function WikiCategories({ onClose }) {
const [cats, setCats] = useState([])
const [editing, setEditing] = useState(null) // null = create mode, else category id
const [form, setForm] = useState(EMPTY)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(() => {
api.admin
.listWikiCategories()
.then(setCats)
.catch(() => setError('Could not load sections.'))
}, [])
useEffect(() => {
load()
}, [load])
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
function startEdit(c) {
setEditing(c.id)
setForm({ slug: c.slug, title: c.title, description: c.description || '', sort_order: c.sort_order })
setError('')
}
function reset() {
setEditing(null)
setForm(EMPTY)
}
async function save() {
if (!form.title.trim()) return setError('Title is required.')
if (!editing && !/^[a-z0-9-]+$/.test(form.slug)) {
return setError('Slug must be lowercase letters, numbers, and dashes.')
}
setBusy(true)
setError('')
const payload = {
title: form.title.trim(),
description: form.description.trim(),
sort_order: Number(form.sort_order) || 0,
}
try {
if (editing) await api.admin.updateWikiCategory(editing, payload)
else await api.admin.createWikiCategory({ slug: form.slug, ...payload })
reset()
load()
} catch (err) {
setError(err.message || 'Could not save section.')
} finally {
setBusy(false)
}
}
async function remove(c) {
if (!confirm(`Delete section "${c.title}"? Its ${c.page_count} page(s) become uncategorized.`)) return
setBusy(true)
try {
await api.admin.deleteWikiCategory(c.id)
if (editing === c.id) reset()
load()
} catch (err) {
setError(err.message || 'Could not delete section.')
} finally {
setBusy(false)
}
}
return (
<Modal
title="Wiki sections"
onClose={onClose}
width={640}
footer={
<button onClick={onClose} className="pill">
Done
</button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{/* Create / edit form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Slug</span>
<input
type="text"
value={form.slug}
onChange={set('slug')}
disabled={Boolean(editing)}
className="input"
style={{ fontFamily: 'ui-monospace,Menlo,monospace', opacity: editing ? 0.6 : 1 }}
placeholder="guides"
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" />
</label>
<label style={{ flex: '0 0 90px' }}>
<span className="field-label">Order</span>
<input type="number" value={form.sort_order} onChange={set('sort_order')} className="input" />
</label>
</div>
<label>
<span className="field-label">Description</span>
<input
type="text"
value={form.description}
onChange={set('description')}
className="input"
maxLength={400}
placeholder="Shown under the section heading on the wiki home."
/>
</label>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{editing ? 'Save section' : '+ Add section'}
</button>
{editing && (
<button onClick={reset} disabled={busy} className="pill">
Cancel edit
</button>
)}
</div>
</div>
{/* Existing categories */}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Section</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Pages</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{cats.map((c) => (
<tr key={c.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{c.title}
</td>
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
{c.slug}
</td>
<td className="adm-td dim">{c.page_count}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<span className="link-accent" onClick={() => startEdit(c)}>
Edit
</span>
<span style={{ color: 'var(--line)', margin: '0 8px' }}>·</span>
<span className="link-accent" style={{ color: '#d98b84' }} onClick={() => remove(c)}>
Delete
</span>
</td>
</tr>
))}
{cats.length === 0 && (
<tr>
<td className="adm-td dim" colSpan={4}>
No sections yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</Modal>
)
}

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 }} />

View File

@@ -1,27 +1,54 @@
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII']
// Group published pages under their category, preserving category sort order and
// collecting anything uncategorized into a trailing section.
function groupByCategory(categories, pages) {
const byId = new Map(categories.map((c) => [c.id, { ...c, pages: [] }]))
const uncategorized = []
for (const page of pages) {
const bucket = page.category_id != null ? byId.get(page.category_id) : null
if (bucket) bucket.pages.push(page)
else uncategorized.push(page)
}
const sections = [...byId.values()].filter((c) => c.pages.length > 0)
if (uncategorized.length) {
sections.push({ id: 'uncategorized', title: 'Other Pages', description: '', pages: uncategorized })
}
return sections
}
// Short blurbs for the seeded categories (the list endpoint returns title/slug only).
const BLURBS = {
'new-player-guide': 'First steps, basic survival, and early goals.',
'maps-atlas': 'Regions, towns, routes, and travel notes.',
systems: 'Shard mechanics and custom features.',
items: 'Equipment, treasures, rewards, and curiosities.',
monsters: 'Creatures, bosses, spawns, and dangers.',
crafting: 'Professions, materials, recipes, and tools.',
lore: 'Stories, places, factions, and mysteries.',
rules: 'Player conduct, shard expectations, and policies.',
function PageCard({ page }) {
return (
<Link to={`/wiki/${page.slug}`} className="card" style={{ padding: 22 }}>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
{page.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
{page.excerpt || 'Open the guide →'}
</p>
</Link>
)
}
export default function Wiki() {
const { loading, error, data } = useAsync(() => api.wiki())
const pages = data || []
const [searchParams] = useSearchParams()
const activeCategory = searchParams.get('category')
const { loading, error, data } = useAsync(() =>
Promise.all([api.wikiCategories(), api.wiki()]).then(([categories, pages]) => ({
categories,
pages,
})),
)
const allSections = data ? groupByCategory(data.categories, data.pages) : []
const sections = activeCategory
? allSections.filter((s) => s.slug === activeCategory)
: allSections
const hasPages = data && data.pages.length > 0
return (
<PublicLayout section="wiki">
@@ -30,26 +57,43 @@ export default function Wiki() {
center
eyebrow="Knowledge base"
title="Mysticmoon Wiki"
lead="A calm starting point for shard guides, maps, systems, items, monsters, crafting, lore, and rules."
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
/>
{loading && <Loading />}
{error && <ErrorState message="Could not load the wiki right now." />}
{!loading && !error && pages.length === 0 && <EmptyState>No wiki pages yet.</EmptyState>}
<section className="grid-4">
{pages.map((p, i) => (
<Link key={p.slug} to={`/wiki/${p.slug}`} className="card" style={{ padding: 22 }}>
<span className="display" style={{ color: 'var(--accent)', fontSize: '1.4rem', marginBottom: 10 }}>
{ROMAN[i] || i + 1}
</span>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
{p.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
{BLURBS[p.slug] || 'Open the guide →'}
</p>
{!loading && !error && !hasPages && <EmptyState>No wiki pages yet.</EmptyState>}
{!loading && !error && activeCategory && (
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
All sections
</Link>
))}
</section>
</p>
)}
{!loading && !error && hasPages && activeCategory && sections.length === 0 && (
<EmptyState>No pages in this section yet.</EmptyState>
)}
{sections.map((section) => (
<section key={section.id} style={{ marginTop: 36 }}>
<h2
className="display"
style={{ margin: '0 0 4px', fontSize: '1.5rem', color: 'var(--accent)' }}
>
{section.title}
</h2>
{section.description && (
<p className="muted" style={{ margin: '0 0 16px', fontSize: '0.95rem' }}>
{section.description}
</p>
)}
<div className="grid-4" style={{ marginTop: section.description ? 0 : 12 }}>
{section.pages.map((p) => (
<PageCard key={p.slug} page={p} />
))}
</div>
</section>
))}
</div>
</PublicLayout>
)

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>