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