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

@@ -284,7 +284,7 @@ phase if preferred). Do not merge a phase that hasn't been verified.
### Phase 0 — Branch & scaffolding ✅ (this doc) ### Phase 0 — Branch & scaffolding ✅ (this doc)
- `wiki-upgrade` branch created; this spec committed. - `wiki-upgrade` branch created; this spec committed.
### Phase 1 — Foundation & safety (highest value) ### Phase 1 — Foundation & safety (highest value)
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published, - Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
published_at, sort_order, FULLTEXT), update `seed.js`. published_at, sort_order, FULLTEXT), update `seed.js`.
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD; - Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
@@ -293,6 +293,11 @@ phase if preferred). Do not merge a phase that hasn't been verified.
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor. `WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
- **Exit check**: existing pages still render; XSS payload in body is neutralized; - **Exit check**: existing pages still render; XSS payload in body is neutralized;
draft pages hidden from the public list/article. draft pages hidden from the public list/article.
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
the public API and are absent from the public list while visible in admin; public
index is data-driven (categories + sections); article shows category breadcrumb;
client builds and server boots with no errors.
### Phase 2 — Authoring UX ### Phase 2 — Authoring UX
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images. - TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.

View File

@@ -8,6 +8,7 @@
"name": "uomysticmoon-client", "name": "uomysticmoon-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.11",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2"
@@ -1197,6 +1198,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1311,6 +1319,15 @@
} }
} }
}, },
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.380", "version": "1.5.380",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.4.11",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2"

View File

@@ -49,7 +49,8 @@ export const api = {
status: () => req('/public/status'), status: () => req('/public/status'),
posts: (category) => req(`/public/posts/${category}`), posts: (category) => req(`/public/posts/${category}`),
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`), 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}`), wikiPage: (slug) => req(`/public/wiki/${slug}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }), contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
@@ -69,11 +70,18 @@ export const api = {
fd.append('image', file) fd.append('image', file)
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true }) 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}`), getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }), createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', 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' }), 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'), getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }), updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), 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 { shortDate } from '../../../lib/format.js'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import WikiEditor from './WikiEditor.jsx' import WikiEditor from './WikiEditor.jsx'
import WikiCategories from './WikiCategories.jsx'
export default function WikiAdmin() { export default function WikiAdmin() {
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), []) const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick]) const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick])
const [editing, setEditing] = useState(null) // null | 'new' | slug const [editing, setEditing] = useState(null) // null | 'new' | slug
const [managingCats, setManagingCats] = useState(false)
const pages = data || [] const pages = data || []
return ( return (
@@ -18,10 +20,15 @@ export default function WikiAdmin() {
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}> <p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure {pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
</p> </p>
<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"> <button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New page + New page
</button> </button>
</div> </div>
</div>
{loading && <Loading />} {loading && <Loading />}
{error && <ErrorState message="Could not load wiki pages." />} {error && <ErrorState message="Could not load wiki pages." />}
@@ -32,7 +39,8 @@ export default function WikiAdmin() {
<thead> <thead>
<tr> <tr>
<th className="adm-th">Page</th> <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">Updated</th>
<th className="adm-th" /> <th className="adm-th" />
</tr> </tr>
@@ -42,9 +50,15 @@ export default function WikiAdmin() {
<tr key={w.slug}> <tr key={w.slug}>
<td className="adm-td" style={{ color: 'var(--head)' }}> <td className="adm-td" style={{ color: 'var(--head)' }}>
{w.title} {w.title}
</td> <span
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}> style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
>
{w.slug} {w.slug}
</span>
</td>
<td className="adm-td dim">{w.category_title || '—'}</td>
<td className="adm-td">
<StatusPill published={w.published} />
</td> </td>
<td className="adm-td dim">{shortDate(w.updated_at)}</td> <td className="adm-td dim">{shortDate(w.updated_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}> <td className="adm-td" style={{ textAlign: 'right' }}>
@@ -54,6 +68,13 @@ export default function WikiAdmin() {
</td> </td>
</tr> </tr>
))} ))}
{pages.length === 0 && (
<tr>
<td className="adm-td dim" colSpan={5}>
No wiki pages yet.
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -69,6 +90,37 @@ export default function WikiAdmin() {
}} }}
/> />
)} )}
{managingCats && (
<WikiCategories
onClose={() => {
setManagingCats(false)
reload() // section titles may have changed
}}
/>
)}
</section> </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 }) { export default function WikiEditor({ slug, onClose, onSaved }) {
const isEdit = Boolean(slug) 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 [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') 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(() => { useEffect(() => {
if (!isEdit) return if (!isEdit) return
let active = true let active = true
api.admin api.admin
.getWiki(slug) .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.')) .catch(() => active && setError('Could not load this page.'))
.finally(() => active && setLoading(false)) .finally(() => active && setLoading(false))
return () => { return () => {
@@ -24,14 +55,26 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value })) 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() { async function save() {
if (!form.title.trim()) return setError('Title is required.') 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) setBusy(true)
setError('') setError('')
try { try {
if (isEdit) await api.admin.updateWiki(slug, { title: form.title.trim(), body: form.body }) if (isEdit) await api.admin.updateWiki(slug, payload())
else await api.admin.createWiki({ slug: form.slug, title: form.title.trim(), body: form.body }) else await api.admin.createWiki({ slug: form.slug, ...payload() })
onSaved() onSaved()
} catch (err) { } catch (err) {
setError(err.message || 'Could not save.') setError(err.message || 'Could not save.')
@@ -67,7 +110,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
Cancel Cancel
</button> </button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq"> <button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save'} {busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
</button> </button>
</> </>
} }
@@ -93,6 +136,40 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
<span className="field-label">Title</span> <span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" /> <input type="text" value={form.title} onChange={set('title')} className="input" />
</label> </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> <label>
<span className="field-label">Body (HTML use &lt;h2&gt; for the table of contents)</span> <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 }} /> <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 PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx' import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js' import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.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). function PageCard({ page }) {
const BLURBS = { return (
'new-player-guide': 'First steps, basic survival, and early goals.', <Link to={`/wiki/${page.slug}`} className="card" style={{ padding: 22 }}>
'maps-atlas': 'Regions, towns, routes, and travel notes.', <h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
systems: 'Shard mechanics and custom features.', {page.title}
items: 'Equipment, treasures, rewards, and curiosities.', </h3>
monsters: 'Creatures, bosses, spawns, and dangers.', <p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
crafting: 'Professions, materials, recipes, and tools.', {page.excerpt || 'Open the guide →'}
lore: 'Stories, places, factions, and mysteries.', </p>
rules: 'Player conduct, shard expectations, and policies.', </Link>
)
} }
export default function Wiki() { export default function Wiki() {
const { loading, error, data } = useAsync(() => api.wiki()) const [searchParams] = useSearchParams()
const pages = data || [] 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 ( return (
<PublicLayout section="wiki"> <PublicLayout section="wiki">
@@ -30,26 +57,43 @@ export default function Wiki() {
center center
eyebrow="Knowledge base" eyebrow="Knowledge base"
title="Mysticmoon Wiki" 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 />} {loading && <Loading />}
{error && <ErrorState message="Could not load the wiki right now." />} {error && <ErrorState message="Could not load the wiki right now." />}
{!loading && !error && pages.length === 0 && <EmptyState>No wiki pages yet.</EmptyState>} {!loading && !error && !hasPages && <EmptyState>No wiki pages yet.</EmptyState>}
<section className="grid-4">
{pages.map((p, i) => ( {!loading && !error && activeCategory && (
<Link key={p.slug} to={`/wiki/${p.slug}`} className="card" style={{ padding: 22 }}> <p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
<span className="display" style={{ color: 'var(--accent)', fontSize: '1.4rem', marginBottom: 10 }}> <Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{ROMAN[i] || i + 1} All sections
</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>
</Link> </Link>
</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> </section>
))}
</div> </div>
</PublicLayout> </PublicLayout>
) )

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import PublicLayout from '../../components/PublicLayout.jsx' import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js' import { useAsync } from '../../lib/useAsync.js'
@@ -13,11 +14,13 @@ function slugify(text) {
.replace(/(^-|-$)/g, '') .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) { function buildArticle(body) {
if (!body) return { html: '', toc: [] } if (!body) return { html: '', toc: [] }
if (typeof window === 'undefined' || !window.DOMParser) return { html: body, toc: [] } if (typeof window === 'undefined' || !window.DOMParser) return { html: '', toc: [] }
const doc = new DOMParser().parseFromString(body, 'text/html') const safe = DOMPurify.sanitize(body)
const doc = new DOMParser().parseFromString(safe, 'text/html')
const toc = [] const toc = []
doc.querySelectorAll('h2').forEach((h, i) => { doc.querySelectorAll('h2').forEach((h, i) => {
const id = slugify(h.textContent || '') || `section-${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' }}> <Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Wiki Wiki
</Link> </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>/</span>
<span>{page.title}</span> <span>{page.title}</span>
</p> </p>

View File

@@ -28,15 +28,34 @@ CREATE TABLE IF NOT EXISTS posts (
INDEX idx_posts_feed (category, published, published_at) INDEX idx_posts_feed (category, published, published_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a
-- fresh install. Pages reference a category (nullable = "Uncategorized").
CREATE TABLE IF NOT EXISTS wiki_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
description VARCHAR(400) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_pages ( CREATE TABLE IF NOT EXISTS wiki_pages (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE, slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL, body MEDIUMTEXT NULL,
excerpt VARCHAR(400) NULL,
category_id INT NULL,
published TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
updated_by INT NULL, updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL published_at DATETIME NULL,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL,
FULLTEXT INDEX idx_wiki_search (title, body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
@@ -57,3 +76,15 @@ CREATE TABLE IF NOT EXISTS activity_log (
CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_activity_created (created_at) INDEX idx_activity_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
-- (The category foreign key is only added on fresh installs; on upgraded databases
-- referential integrity for category_id is enforced in application code.)
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);

View File

@@ -22,24 +22,39 @@ const DEFAULT_SETTINGS = {
site_title: 'UOMysticmoon', site_title: 'UOMysticmoon',
} }
// The 8 starter wiki categories (editable later via the admin panel). // Starter wiki sections (editable later via the admin panel).
// [slug, title, description, sort_order]
const WIKI_CATEGORIES = [
['guides', 'Guides', 'Getting started and how-to guides.', 10],
['world', 'World & Lore', 'Regions, maps, and the story of Mysticmoon.', 20],
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
['community', 'Community & Rules', 'Player conduct and shard policies.', 40],
]
// The 8 starter pages, each mapped to a section. [slug, title, body, categorySlug]
const WIKI_PAGES = [ const WIKI_PAGES = [
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.'], ['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.', 'guides'],
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.'], ['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.', 'world'],
['systems', 'Server Systems', 'Shard mechanics and custom features.'], ['lore', 'Lore', 'Stories, places, factions, and mysteries.', 'world'],
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.'], ['systems', 'Server Systems', 'Shard mechanics and custom features.', 'gameplay'],
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.'], ['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.', 'gameplay'],
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.'], ['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', 'gameplay'],
['lore', 'Lore', 'Stories, places, factions, and mysteries.'], ['crafting', 'Crafting', 'Professions, materials, recipes, and tools.', 'gameplay'],
['rules', 'Rules', 'Player conduct, shard expectations, and policies.'], ['rules', 'Rules', 'Player conduct, shard expectations, and policies.', 'community'],
] ]
async function seedDefaults() { async function seedDefaults() {
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
await settingsDb.seedDefault(key, value) await settingsDb.seedDefault(key, value)
} }
for (const [slug, title, body] of WIKI_PAGES) { for (const [slug, title, description, sortOrder] of WIKI_CATEGORIES) {
await wikiDb.seedDefaultCategory(slug, title, description, sortOrder)
}
for (const [slug, title, body, categorySlug] of WIKI_PAGES) {
await wikiDb.seedDefault(slug, title, body) await wikiDb.seedDefault(slug, title, body)
// Attach to its section (only if not already categorized — safe re-run /
// migration of pages seeded before the wiki upgrade).
await wikiDb.assignCategoryBySlug(slug, categorySlug)
} }
log.info('settings and wiki defaults ensured') log.info('settings and wiki defaults ensured')
} }

228
server/package-lock.json generated
View File

@@ -21,7 +21,8 @@
"mariadb": "^3.3.1", "mariadb": "^3.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^2.0.1", "multer": "^2.0.1",
"nodemailer": "^9.0.1" "nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4" "nodemon": "^3.1.4"
@@ -345,6 +346,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -354,6 +361,15 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -382,6 +398,73 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -432,6 +515,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -468,6 +563,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -729,6 +836,25 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -829,6 +955,15 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -878,6 +1013,15 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1097,6 +1241,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1221,6 +1383,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1236,6 +1404,12 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@@ -1249,6 +1423,34 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1362,6 +1564,21 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sanitize-html": {
"version": "2.17.5",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1510,6 +1727,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -9,7 +9,12 @@
"seed": "node db/seed.js", "seed": "node db/seed.js",
"test": "echo \"no tests yet\" && exit 0" "test": "echo \"no tests yet\" && exit 0"
}, },
"keywords": ["express", "mariadb", "jwt", "bcrypt"], "keywords": [
"express",
"mariadb",
"jwt",
"bcrypt"
],
"author": "whitlocktech", "author": "whitlocktech",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -25,7 +30,8 @@
"mariadb": "^3.3.1", "mariadb": "^3.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^2.0.1", "multer": "^2.0.1",
"nodemailer": "^9.0.1" "nodemailer": "^9.0.1",
"sanitize-html": "^2.17.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4" "nodemon": "^3.1.4"

View File

@@ -1,33 +1,157 @@
const { query } = require('../../utils/db') const { query } = require('../../utils/db')
async function listSummaries() { // Full page row + joined category fields.
return query('SELECT slug, title, updated_at FROM wiki_pages ORDER BY title ASC') const PAGE_COLS =
'p.id, p.slug, p.title, p.body, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_by, p.created_at, p.updated_at, p.published_at, ' +
'c.slug AS category_slug, c.title AS category_title'
// List rows omit the body (lighter payload for indexes/tables).
const SUMMARY_COLS =
'p.id, p.slug, p.title, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_at, p.published_at, c.slug AS category_slug, c.title AS category_title'
const FROM = 'FROM wiki_pages p LEFT JOIN wiki_categories c ON c.id = p.category_id'
const ORDER = 'ORDER BY p.sort_order ASC, p.title ASC'
// ── Page reads ─────────────────────────────────────────────────────────
// Published summaries (public). Optional category filter by id.
async function listPublishedSummaries(categoryId = null) {
if (categoryId != null) {
return query(
`SELECT ${SUMMARY_COLS} ${FROM} WHERE p.published = 1 AND p.category_id = ? ${ORDER}`,
[categoryId],
)
}
return query(`SELECT ${SUMMARY_COLS} ${FROM} WHERE p.published = 1 ${ORDER}`)
}
// All summaries (admin), with optional category / status filters.
async function listAllSummaries({ categoryId = null, published = null } = {}) {
const where = []
const params = []
if (categoryId != null) {
where.push('p.category_id = ?')
params.push(categoryId)
}
if (published != null) {
where.push('p.published = ?')
params.push(published ? 1 : 0)
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
return query(`SELECT ${SUMMARY_COLS} ${FROM} ${clause} ${ORDER}`, params)
} }
async function findBySlug(slug) { async function findBySlug(slug) {
const rows = await query('SELECT * FROM wiki_pages WHERE slug = ? LIMIT 1', [slug]) const rows = await query(`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? LIMIT 1`, [slug])
return rows[0] || null return rows[0] || null
} }
async function insert({ slug, title, body, updatedBy = null }) { async function findPublishedBySlug(slug) {
const rows = await query(
`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? AND p.published = 1 LIMIT 1`,
[slug],
)
return rows[0] || null
}
// ── Page writes ────────────────────────────────────────────────────────
async function insert({
slug,
title,
body = null,
excerpt = null,
categoryId = null,
published = true,
sortOrder = 0,
updatedBy = null,
}) {
const res = await query( const res = await query(
'INSERT INTO wiki_pages (slug, title, body, updated_by) VALUES (?, ?, ?, ?)', 'INSERT INTO wiki_pages (slug, title, body, excerpt, category_id, published, sort_order, published_at, updated_by) ' +
[slug, title, body || null, updatedBy], 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
slug,
title,
body,
excerpt,
categoryId,
published ? 1 : 0,
sortOrder,
published ? new Date() : null,
updatedBy,
],
) )
return res.insertId return res.insertId
} }
async function updateBySlug(slug, { title, body, updatedBy = null }) { // Dynamic update — only the provided columns are written.
await query( async function updateBySlug(slug, fields) {
'UPDATE wiki_pages SET title = ?, body = ?, updated_by = ? WHERE slug = ?', const cols = []
[title, body || null, updatedBy, slug], const params = []
) for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(slug)
await query(`UPDATE wiki_pages SET ${cols.join(', ')} WHERE slug = ?`, params)
} }
async function deleteBySlug(slug) { async function deleteBySlug(slug) {
return query('DELETE FROM wiki_pages WHERE slug = ?', [slug]) return query('DELETE FROM wiki_pages WHERE slug = ?', [slug])
} }
// ── Categories ─────────────────────────────────────────────────────────
const CAT_COLS = 'id, slug, title, description, sort_order, created_at, updated_at'
// Categories with page counts (total + published) for index/admin views.
async function listCategories() {
return query(
`SELECT c.id, c.slug, c.title, c.description, c.sort_order, c.created_at, c.updated_at,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id) AS page_count,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id AND p.published = 1) AS published_count
FROM wiki_categories c
ORDER BY c.sort_order ASC, c.title ASC`,
)
}
async function findCategoryBySlug(slug) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function findCategoryById(id) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertCategory({ slug, title, description = null, sortOrder = 0 }) {
const res = await query(
'INSERT INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description, sortOrder],
)
return res.insertId
}
async function updateCategory(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE wiki_categories SET ${cols.join(', ')} WHERE id = ?`, params)
}
// Detach pages first (works even on upgraded DBs that lack the FK), then delete.
async function deleteCategory(id) {
await query('UPDATE wiki_pages SET category_id = NULL WHERE category_id = ?', [id])
return query('DELETE FROM wiki_categories WHERE id = ?', [id])
}
// ── Seeding (idempotent) ───────────────────────────────────────────────
async function seedDefault(slug, title, body) { async function seedDefault(slug, title, body) {
await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [ await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [
slug, slug,
@@ -36,11 +160,38 @@ async function seedDefault(slug, title, body) {
]) ])
} }
async function seedDefaultCategory(slug, title, description, sortOrder = 0) {
await query(
'INSERT IGNORE INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description || null, sortOrder],
)
}
// Assign a seeded page to a category by slug, only if not already categorized —
// migrates pre-upgrade pages without clobbering manual changes.
async function assignCategoryBySlug(pageSlug, categorySlug) {
await query(
'UPDATE wiki_pages SET category_id = (SELECT id FROM wiki_categories WHERE slug = ?) ' +
'WHERE slug = ? AND category_id IS NULL',
[categorySlug, pageSlug],
)
}
module.exports = { module.exports = {
listSummaries, listPublishedSummaries,
listAllSummaries,
findBySlug, findBySlug,
findPublishedBySlug,
insert, insert,
updateBySlug, updateBySlug,
deleteBySlug, deleteBySlug,
listCategories,
findCategoryBySlug,
findCategoryById,
insertCategory,
updateCategory,
deleteCategory,
seedDefault, seedDefault,
seedDefaultCategory,
assignCategoryBySlug,
} }

View File

@@ -1,20 +1,62 @@
const wikiDb = require('./wiki.db') const wikiDb = require('./wiki.db')
const { cleanBody } = require('../../utils/sanitizeHtml')
async function list() { // ── Pages ──────────────────────────────────────────────────────────────
return wikiDb.listSummaries() async function listPublished(categoryId = null) {
return wikiDb.listPublishedSummaries(categoryId)
}
async function listAll(filters = {}) {
return wikiDb.listAllSummaries(filters)
} }
async function getBySlug(slug) { async function getBySlug(slug) {
return wikiDb.findBySlug(slug) return wikiDb.findBySlug(slug)
} }
async function create({ slug, title, body, updatedBy }) { async function getPublishedBySlug(slug) {
await wikiDb.insert({ slug, title, body, updatedBy }) return wikiDb.findPublishedBySlug(slug)
}
async function create({ slug, title, body, excerpt, categoryId, published, updatedBy }) {
await wikiDb.insert({
slug,
title,
body: cleanBody(body),
excerpt: excerpt || null,
categoryId: categoryId ?? null,
published: published !== false, // default published unless explicitly false
updatedBy,
})
return wikiDb.findBySlug(slug) return wikiDb.findBySlug(slug)
} }
async function update(slug, { title, body, updatedBy }) { // Partial update — only keys present in `input` are written. Body is sanitized;
await wikiDb.updateBySlug(slug, { title, body, updatedBy }) // published_at is stamped the first time a page goes live.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { updated_by: input.updatedBy ?? null }
if ('title' in input) fields.title = input.title
if ('body' in input) fields.body = cleanBody(input.body)
if ('excerpt' in input) fields.excerpt = input.excerpt || null
if ('categoryId' in input) fields.category_id = input.categoryId ?? null
if ('published' in input) {
fields.published = input.published ? 1 : 0
if (input.published && !current.published_at) fields.published_at = new Date()
}
await wikiDb.updateBySlug(slug, fields)
return wikiDb.findBySlug(slug)
}
async function setPublished(slug, published) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { published: published ? 1 : 0 }
if (published && !current.published_at) fields.published_at = new Date()
await wikiDb.updateBySlug(slug, fields)
return wikiDb.findBySlug(slug) return wikiDb.findBySlug(slug)
} }
@@ -22,4 +64,52 @@ async function remove(slug) {
return wikiDb.deleteBySlug(slug) return wikiDb.deleteBySlug(slug)
} }
module.exports = { list, getBySlug, create, update, remove } // ── Categories ─────────────────────────────────────────────────────────
async function listCategories() {
return wikiDb.listCategories()
}
async function getCategoryBySlug(slug) {
return wikiDb.findCategoryBySlug(slug)
}
async function getCategoryById(id) {
return wikiDb.findCategoryById(id)
}
async function createCategory({ slug, title, description, sortOrder }) {
const id = await wikiDb.insertCategory({ slug, title, description, sortOrder })
return wikiDb.findCategoryById(id)
}
async function updateCategory(id, input) {
const fields = {}
if ('title' in input) fields.title = input.title
if ('slug' in input) fields.slug = input.slug
if ('description' in input) fields.description = input.description || null
if ('sortOrder' in input) fields.sort_order = input.sortOrder
await wikiDb.updateCategory(id, fields)
return wikiDb.findCategoryById(id)
}
async function removeCategory(id) {
return wikiDb.deleteCategory(id)
}
module.exports = {
list: listPublished, // back-compat alias (old callers expected published list)
listPublished,
listAll,
getBySlug,
getPublishedBySlug,
create,
update,
setPublished,
remove,
listCategories,
getCategoryBySlug,
getCategoryById,
createCategory,
updateCategory,
removeCategory,
}

View File

@@ -160,10 +160,17 @@ async function uploadImage(req, res) {
return res.status(201).json({ image_url: imageUrl }) return res.status(201).json({ image_url: imageUrl })
} }
// ── Wiki ────────────────────────────────────────────────────────────── // ── Wiki pages ─────────────────────────────────────────────────────────
async function listWiki(req, res) { async function listWiki(req, res) {
try { try {
return res.json(await wiki.list()) const filters = {}
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
filters.categoryId = category ? category.id : -1 // unknown → match nothing
}
if (req.query.status === 'draft') filters.published = false
if (req.query.status === 'published') filters.published = true
return res.json(await wiki.listAll(filters))
} catch (err) { } catch (err) {
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })
} }
@@ -179,15 +186,32 @@ async function getWiki(req, res) {
} }
} }
// Resolve a category_id from the request, validating it exists. Returns
// { ok, value } so the caller can distinguish "not provided" from "invalid".
async function resolveCategoryId(body) {
if (!('category_id' in body) || body.category_id == null || body.category_id === '') {
return { ok: true, value: null }
}
const category = await wiki.getCategoryById(Number(body.category_id))
if (!category) return { ok: false }
return { ok: true, value: category.id }
}
async function createWiki(req, res) { async function createWiki(req, res) {
try { try {
if (await wiki.getBySlug(req.body.slug)) { if (await wiki.getBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A page with that slug already exists' }) return res.status(409).json({ message: 'A page with that slug already exists' })
} }
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
const page = await wiki.create({ const page = await wiki.create({
slug: req.body.slug, slug: req.body.slug,
title: req.body.title, title: req.body.title,
body: req.body.body || null, body: req.body.body || null,
excerpt: req.body.excerpt || null,
categoryId: cat.value,
published: req.body.published !== false,
updatedBy: req.user.id, updatedBy: req.user.id,
}) })
await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } }) await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } })
@@ -202,11 +226,19 @@ async function updateWiki(req, res) {
try { try {
const existing = await wiki.getBySlug(req.params.slug) const existing = await wiki.getBySlug(req.params.slug)
if (!existing) return res.status(404).json({ message: 'Not found' }) if (!existing) return res.status(404).json({ message: 'Not found' })
const page = await wiki.update(req.params.slug, {
title: req.body.title, const input = { updatedBy: req.user.id }
body: req.body.body || null, if ('title' in req.body) input.title = req.body.title
updatedBy: req.user.id, if ('body' in req.body) input.body = req.body.body || null
}) if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null
if ('published' in req.body) input.published = Boolean(req.body.published)
if ('category_id' in req.body) {
const cat = await resolveCategoryId(req.body)
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
input.categoryId = cat.value
}
const page = await wiki.update(req.params.slug, input)
await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } }) await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } })
return res.json(page) return res.json(page)
} catch (err) { } catch (err) {
@@ -215,6 +247,22 @@ async function updateWiki(req, res) {
} }
} }
async function publishWiki(req, res) {
try {
const page = await wiki.setPublished(req.params.slug, Boolean(req.body.published))
if (!page) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'wiki.publish',
detail: { slug: req.params.slug, published: Boolean(req.body.published) },
})
return res.json(page)
} catch (err) {
log.error('publishWiki', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWiki(req, res) { async function deleteWiki(req, res) {
try { try {
await wiki.remove(req.params.slug) await wiki.remove(req.params.slug)
@@ -225,6 +273,73 @@ async function deleteWiki(req, res) {
} }
} }
// ── Wiki categories ────────────────────────────────────────────────────
async function listWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function createWikiCategory(req, res) {
try {
if (await wiki.getCategoryBySlug(req.body.slug)) {
return res.status(409).json({ message: 'A category with that slug already exists' })
}
const category = await wiki.createCategory({
slug: req.body.slug,
title: req.body.title,
description: req.body.description || null,
sortOrder: Number(req.body.sort_order) || 0,
})
await activity.log({ req, action: 'wiki.category.create', detail: { slug: category.slug } })
return res.status(201).json(category)
} catch (err) {
log.error('createWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function updateWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
const input = {}
if ('title' in req.body) input.title = req.body.title
if ('description' in req.body) input.description = req.body.description || null
if ('sort_order' in req.body) input.sortOrder = Number(req.body.sort_order) || 0
if ('slug' in req.body && req.body.slug !== existing.slug) {
const clash = await wiki.getCategoryBySlug(req.body.slug)
if (clash) return res.status(409).json({ message: 'A category with that slug already exists' })
input.slug = req.body.slug
}
const category = await wiki.updateCategory(id, input)
await activity.log({ req, action: 'wiki.category.update', detail: { id } })
return res.json(category)
} catch (err) {
log.error('updateWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function deleteWikiCategory(req, res) {
const id = Number(req.params.id)
try {
const existing = await wiki.getCategoryById(id)
if (!existing) return res.status(404).json({ message: 'Not found' })
await wiki.removeCategory(id) // pages in it become uncategorized
await activity.log({ req, action: 'wiki.category.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteWikiCategory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Settings ────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────
async function getSettings(req, res) { async function getSettings(req, res) {
try { try {
@@ -346,7 +461,12 @@ module.exports = {
getWiki, getWiki,
createWiki, createWiki,
updateWiki, updateWiki,
publishWiki,
deleteWiki, deleteWiki,
listWikiCategories,
createWikiCategory,
updateWikiCategory,
deleteWikiCategory,
getSettings, getSettings,
updateSettings, updateSettings,
listActivity, listActivity,

View File

@@ -65,22 +65,57 @@ adminRouter.patch(
) )
adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost) adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost)
// ── Wiki ────────────────────────────────────────────────────────────── // ── Wiki categories (static paths registered before /wiki/:slug) ───────
adminRouter.get('/wiki/categories', ctrl.listWikiCategories)
adminRouter.post(
'/wiki/categories',
body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.createWikiCategory,
)
adminRouter.put(
'/wiki/categories/:id',
param('id').isInt(),
body('slug').optional().matches(/^[a-z0-9-]+$/),
body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('sort_order').optional().isInt(),
validate,
ctrl.updateWikiCategory,
)
adminRouter.delete('/wiki/categories/:id', param('id').isInt(), validate, ctrl.deleteWikiCategory)
// ── Wiki pages ─────────────────────────────────────────────────────────
adminRouter.get('/wiki', ctrl.listWiki) adminRouter.get('/wiki', ctrl.listWiki)
adminRouter.post( adminRouter.post(
'/wiki', '/wiki',
body('slug').matches(/^[a-z0-9-]+$/), body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty(), body('title').isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
validate, validate,
ctrl.createWiki, ctrl.createWiki,
) )
adminRouter.get('/wiki/:slug', ctrl.getWiki) adminRouter.get('/wiki/:slug', ctrl.getWiki)
adminRouter.put( adminRouter.put(
'/wiki/:slug', '/wiki/:slug',
body('title').isString().trim().notEmpty(), body('title').optional().isString().trim().notEmpty().isLength({ max: 200 }),
body('excerpt').optional({ values: 'falsy' }).isString().isLength({ max: 400 }),
body('category_id').optional({ values: 'null' }).isInt(),
body('published').optional().isBoolean(),
validate, validate,
ctrl.updateWiki, ctrl.updateWiki,
) )
adminRouter.patch(
'/wiki/:slug/publish',
body('published').isBoolean(),
validate,
ctrl.publishWiki,
)
adminRouter.delete('/wiki/:slug', ctrl.deleteWiki) adminRouter.delete('/wiki/:slug', ctrl.deleteWiki)
// ── Settings ────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────

View File

@@ -50,9 +50,23 @@ async function getPost(req, res) {
} }
} }
async function getWikiCategories(req, res) {
try {
return res.json(await wiki.listCategories())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiList(req, res) { async function getWikiList(req, res) {
try { try {
return res.json(await wiki.list()) let categoryId = null
if (req.query.category) {
const category = await wiki.getCategoryBySlug(req.query.category)
if (!category) return res.json([]) // unknown category → no pages
categoryId = category.id
}
return res.json(await wiki.listPublished(categoryId))
} catch (err) { } catch (err) {
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })
} }
@@ -60,7 +74,8 @@ async function getWikiList(req, res) {
async function getWikiPage(req, res) { async function getWikiPage(req, res) {
try { try {
const page = await wiki.getBySlug(req.params.slug) // Public sees published pages only; drafts 404 like any missing page.
const page = await wiki.getPublishedBySlug(req.params.slug)
if (!page) return res.status(404).json({ message: 'Not found' }) if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page) return res.json(page)
} catch (err) { } catch (err) {
@@ -84,6 +99,7 @@ module.exports = {
getStatus, getStatus,
getPosts, getPosts,
getPost, getPost,
getWikiCategories,
getWikiList, getWikiList,
getWikiPage, getWikiPage,
contact, contact,

View File

@@ -25,6 +25,8 @@ publicRouter.post(
publicRouter.get('/posts/:category', siteMode, ctrl.getPosts) publicRouter.get('/posts/:category', siteMode, ctrl.getPosts)
publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost) publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost)
publicRouter.get('/wiki', siteMode, ctrl.getWikiList) publicRouter.get('/wiki', siteMode, ctrl.getWikiList)
// Static path must precede the :slug route so it isn't captured as a slug.
publicRouter.get('/wiki/categories', siteMode, ctrl.getWikiCategories)
publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage) publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage)
module.exports = publicRouter module.exports = publicRouter

View File

@@ -0,0 +1,45 @@
const sanitizeHtml = require('sanitize-html')
// Allowlist for wiki/post body HTML. Anything not listed is stripped. This runs
// on every save so the stored value is already safe; the client re-sanitizes on
// render as defense in depth. Tuned for rich-text content from the admin editor.
const OPTIONS = {
allowedTags: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
'a', 'img', 'figure', 'figcaption',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
},
// http/https for links and images, mailto for links, plus relative URLs so
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: { img: ['http', 'https'] },
allowProtocolRelative: false,
// Force safe rel on links that open a new tab; drop empty/odd attributes.
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
},
disallowedTagsMode: 'discard',
}
/**
* Sanitize a block of body HTML against the allowlist above.
* Null/empty input is returned unchanged.
* @param {string|null|undefined} html
* @returns {string|null|undefined}
*/
function cleanBody(html) {
if (html == null || html === '') return html
return sanitizeHtml(String(html), OPTIONS)
}
module.exports = { cleanBody, OPTIONS }