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:
@@ -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.
|
||||||
|
|||||||
17
client/package-lock.json
generated
17
client/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
182
client/src/routes/admin/views/WikiCategories.jsx
Normal file
182
client/src/routes/admin/views/WikiCategories.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <h2> for the table of contents)</span>
|
<span className="field-label">Body (HTML — use <h2> for the table of contents)</span>
|
||||||
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
|
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
228
server/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
45
server/src/utils/sanitizeHtml.js
Normal file
45
server/src/utils/sanitizeHtml.js
Normal 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 }
|
||||||
Reference in New Issue
Block a user