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,9 +20,14 @@ 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>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq"> <div style={{ display: 'flex', gap: 10 }}>
+ New page <button onClick={() => setManagingCats(true)} className="pill">
</button> Manage sections
</button>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New page
</button>
</div>
</div> </div>
{loading && <Loading />} {loading && <Loading />}
@@ -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}
<span
style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
>
{w.slug}
</span>
</td> </td>
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}> <td className="adm-td dim">{w.category_title || '—'}</td>
{w.slug} <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>
</section> )}
{!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> </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,
updated_by INT NULL, excerpt VARCHAR(400) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, category_id INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published TINYINT(1) NOT NULL DEFAULT 1,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL sort_order INT NOT NULL DEFAULT 0,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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 }