Detailed design + process for upgrading the CMS wiki: rich-text (TipTap) editing, server+client sanitization, categories, drafts/publish, tags, internal links/backlinks, inline images, FULLTEXT search, and revision history. Staff-only (admin/editor). Additive, idempotent schema migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
UOMysticmoon Website — Wiki Upgrade Spec
Branch:
wiki-upgrade. This document is the contract for upgrading the CMS wiki from a flat single-table page store into a feature-complete wiki. It follows the project workflow: design (this doc) → build in phases → verify.Companion to
BACKEND_DESIGN.md; reuses its stack, auth, logging, and Docker decisions unchanged.
1. Goal & scope
Turn the wiki into something that behaves like a typical wiki, while staying inside the existing Node/Express + MariaDB + React/Vite architecture and the staff-only auth model (admin/editor — no new roles, no public contributions).
In scope
| Feature | Summary |
|---|---|
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
| Sanitization | Server-side allowlist on save and client-side on render (fixes today's stored-XSS gap) |
| Categories / sections | First-class wiki_categories table; replaces hardcoded frontend blurbs |
| Drafts & publish | published + published_at, mirroring the posts pattern |
| Tags | Many-to-many tags with filtering |
| Internal links | [[slug]]-style links authored in the editor; red-link detection |
| Backlinks | "Linked from" list, maintained on save |
| Inline images | Reuse/generalize the existing multer upload for in-body images |
| Search | MariaDB FULLTEXT over title + body |
| Revision history | Per-save snapshots with view / diff / restore |
Out of scope (this branch)
- Public/player editing or suggestion workflow, moderation/review queues.
- New roles or per-page ACLs (all staff with a login can edit all pages).
- Real-time collaborative editing, comments/discussion pages, file attachments other than images, page templates/transclusion, multilingual pages.
Decisions locked from planning
- Editor: TipTap, storing HTML (not Markdown, not JSON).
- Search: MariaDB FULLTEXT (no new infrastructure).
- Revision history and search are included (recommended additions beyond the minimum requested set).
- Authoring is admin + editor (
isLoggedIn); no anonymous edits.
2. Current state (baseline being replaced)
| Layer | Today | File |
|---|---|---|
| Schema | flat wiki_pages(slug,title,body,updated_by,timestamps) |
server/db/schema.sql:31 |
| Model | thin CRUD by slug | server/src/model/wiki/wiki.db.js, wiki.model.js |
| Public API | GET /public/wiki, GET /public/wiki/:slug |
public.controller.js:53 |
| Admin API | GET/POST/PUT/DELETE /admin/wiki[...] |
admin.controller.js:163, admin.routes.js:68 |
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | Wiki.jsx, WikiArticle.jsx |
| Admin UI | raw-HTML <textarea> modal |
WikiAdmin.jsx, WikiEditor.jsx |
| API client | api.wiki, api.admin.*Wiki |
client/src/api/client.js:52 |
Known issues this upgrade resolves
- Stored XSS: body is raw HTML rendered with
dangerouslySetInnerHTMLand never sanitized (WikiArticle.jsx:91). - Category blurbs and ordering are faked in the component (Wiki.jsx:11), not data.
- No drafts (every save is instantly public), no history, no search, no tags, no links.
3. Data model
utf8mb4, InnoDB throughout. All changes are additive and idempotent so
ensureSchema() upgrades existing databases on boot with no data loss. New columns
are nullable or have safe defaults; existing pages default to published = 1 so
nothing disappears on deploy.
3.1 wiki_categories (new)
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. guides |
| title | VARCHAR(200) NOT NULL | |
| description | VARCHAR(400) NULL | card teaser on the wiki index |
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
| created_at / updated_at | DATETIME | standard stamps |
3.2 wiki_pages (altered)
Add to the existing table:
| col | type | notes |
|---|---|---|
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
| published_at | DATETIME NULL | set on first publish |
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
| FULLTEXT idx_wiki_search (title, body) | search |
3.3 wiki_tags + wiki_page_tags (new)
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
tag_id FK→wiki_tags ON DELETE CASCADE,
PRIMARY KEY(page_id, tag_id) )
3.4 wiki_links (new) — backlinks index
Rebuilt for a page on every save by parsing its body for internal links.
| col | type | notes |
|---|---|---|
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
| INDEX idx_wiki_links_target (target_slug) | backlink lookups |
Backlinks for page X = SELECT source pages WHERE target_slug = X.slug AND source is published.
3.5 wiki_revisions (new) — history
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| title / body / excerpt | snapshot of content at save time | |
| category_id | INT NULL | snapshot |
| editor_id | INT NULL FK→users(id) | who saved |
| change_note | VARCHAR(280) NULL | optional summary |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP |
A revision is written inside the same transaction as each page create/update.
3.6 Seed changes
Rework seed.js: the current 8 hardcoded pages become categories
(title + the blurb currently living in the frontend), each seeded idempotently via a new
seedDefaultCategory. Existing seeded pages are migrated/attached where applicable.
seedDefault for pages stays INSERT IGNORE so reseeding is safe.
4. Backend changes
Keep the model (entity) / db (SQL) split and the route grouping by access level.
4.1 Models (server/src/model/wiki/)
wiki.db.js— add SQL for: category CRUD; page list withcategory,published,q(FULLTEXT) filters and ordering; tag upsert + attach/detach;wiki_linksrebuild; revision insert/list/get; backlink query.wiki.model.js— orchestration. On create/update (single transaction):- sanitize
bodywith the allowlist (§6), - upsert the page,
- insert a
wiki_revisionssnapshot, - parse body for internal links → rebuild
wiki_linksfor the page, - sync tags.
- sanitize
- A small
wiki.links.jshelper: parse internal links out of the saved HTML (anchors written by the editor ashref="/wiki/<slug>"/ adata-wiki-slugattr), return the set of target slugs.
4.2 Public API (/api/v1/public)
| Method | Path | Notes |
|---|---|---|
| GET | /wiki/categories |
ordered categories with page counts |
| GET | /wiki?category=&tag=&q= |
published only; list/filter/search summaries |
| GET | /wiki/:slug |
page + category + tags + backlinks (published only) |
Still passes through the siteMode maintenance gate like other public content.
4.3 Admin API (/api/v1/admin, behind isLoggedIn + noindex)
| Method | Path | Purpose |
|---|---|---|
| GET | /wiki |
all pages incl. drafts (filters: category, tag, q, status) |
| GET | /wiki/:slug |
one page incl. draft, tags, category |
| POST | /wiki |
create (slug, title, body, excerpt, category_id, tags, published) |
| PUT | /wiki/:slug |
update (allows slug rename — see §7) |
| PATCH | /wiki/:slug/publish |
{published} toggle, stamps published_at |
| DELETE | /wiki/:slug |
delete (cascades revisions/links/tags) |
| GET | /wiki/:slug/revisions |
list snapshots |
| GET | /wiki/:slug/revisions/:id |
one snapshot (for diff/preview) |
| POST | /wiki/:slug/revisions/:id/restore |
restore (writes a new revision) |
| GET/POST/PUT/DELETE | /wiki/categories[...] |
category CRUD + reorder |
| GET/POST | /wiki/tags |
list/create tags |
| POST | /uploads |
generalized image upload (see §4.4) → {url} |
Validation via express-validator (slug regex ^[a-z0-9-]+$, title required, etc.),
centralized error handler unchanged. Every write logs to activity_log
(wiki.create, wiki.update, wiki.publish, wiki.delete, wiki.revision.restore,
wiki.category.*) following the existing convention.
4.4 Image uploads
Generalize the existing screenshot upload (multer config in admin.routes.js:17)
into a shared POST /admin/uploads returning { url: "/uploads/<file>" }, reused by both
the post editor and the wiki editor. Same size/mime limits. No new storage —
served from the existing uploads/ volume.
5. Frontend changes
5.1 Admin
WikiEditor.jsx— replace the raw-HTML<textarea>with a TipTap editor: bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, image insert (uploads via/admin/uploads), and an internal-link picker ([[-triggered autocomplete over existing slugs; flags red links). Adds: category dropdown, tag input (create-on-type), excerpt field, Save draft / Publish actions, and a History tab (revision list → preview → diff → restore).WikiAdmin.jsx— list gains status (draft/published), category column, and filters; plus a Categories manager (CRUD + drag-to-reorder).
5.2 Public
Wiki.jsx— fully data-driven: categories + real excerpts from the API (delete the hardcodedBLURBS/ROMANconstants), a search box, optional tag filter.WikiArticle.jsx— keep auto-TOC; add category breadcrumb, tag chips, a "Linked from" backlinks section, "last updated by", and render via DOMPurify (dangerouslySetInnerHTMLonly after sanitize).
5.3 API client & routes
- Extend client/src/api/client.js with the new public/admin wiki calls (categories, search params, revisions, tags, uploads).
- Add a public search/category route if needed; admin categories view registered in
App.jsx under
/admin/wiki(sub-tab, no new top-level route required).
5.4 Dependencies (new)
- client:
@tiptap/react,@tiptap/starter-kit,@tiptap/extension-link,@tiptap/extension-image(+ a small diff lib for history, e.g.diff);dompurify. - server:
sanitize-html.
(The client currently ships only React + react-router, so this is the first feature dependency addition — keep the bundle lean, import only the extensions used.)
6. Security
- Two-layer sanitization. Server sanitizes on save with a strict
sanitize-htmlallowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt], strong/em, hr, table basics); strips scripts, event handlers,javascript:URLs, styles. Client re-sanitizes with DOMPurify before render. The stored value is already clean, so even direct DB edits or future API clients can't inject script. - Upload safety unchanged from posts: mime allowlist (png/jpe/gif/webp/avif), 8 MB cap, random filenames, served as static files (no execution).
- Authorization: all mutating wiki/category/tag/upload routes stay behind
isLoggedIn(admin or editor). Public routes are read-only and published-only. - No secrets/logging changes; reuse existing rate-limit, helmet/CSP, noindex.
CSP
img-srcalready covers/uploads.
7. Migration & backward compatibility
- Schema migration is additive; run by
ensureSchema()on boot and shipped inschema.sqlfor fresh containers. UseALTER TABLE ... ADD COLUMN IF NOT EXISTS/ADD INDEXguarded for idempotency (MariaDB 11 supportsIF NOT EXISTS). - Existing pages:
publishedbackfills to1,published_attoupdated_at,category_idleft NULL (surface as "Uncategorized" until assigned). - Slug rename (new capability): on
PUTslug change, update the page slug and best-effort rewrite known internal links pointing at the old slug; old slug is not auto-redirected (acceptable for a staff-curated wiki) — note in release notes. - Public API response shape is extended, not broken: existing fields
(
slug,title,body,updated_at) remain; new fields are additive, so the current frontend keeps working between phases.
8. Implementation process (phased)
Each phase is a self-contained, shippable unit: build → run locally → verify in the
browser preview → commit on wiki-upgrade. Open a PR into main at the end (or per
phase if preferred). Do not merge a phase that hasn't been verified.
Phase 0 — Branch & scaffolding ✅ (this doc)
wiki-upgradebranch created; this spec committed.
Phase 1 — Foundation & safety (highest value)
- Schema: add
wiki_categories, alterwiki_pages(category_id, excerpt, published, published_at, sort_order, FULLTEXT), updateseed.js. - Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD; public list filtered to published + categories endpoint.
- Client: data-driven
Wiki.jsx(remove hardcoded blurbs); DOMPurify render inWikiArticle.jsx; draft/publish + category in the (still-textarea) admin editor. - Exit check: existing pages still render; XSS payload in body is neutralized; draft pages hidden from the public list/article.
Phase 2 — Authoring UX
- TipTap editor replaces the textarea; generalized
/admin/uploads; inline images. - Exit check: create/edit a page with headings, a list, a link, and an inline image; verify it renders sanitized on the public page.
Phase 3 — Connectivity
- Internal
[[slug]]links + red-link detection;wiki_linksrebuild on save; backlinks on the article; tags + tag/category filtering. - Exit check: link page A→B, confirm B shows A under "Linked from"; tag filter works.
Phase 4 — Discovery & trust
- FULLTEXT search (public search box + admin filter); revision history list / diff / restore.
- Exit check: search returns expected pages; edit a page twice, diff the revisions, restore an older one, confirm a new revision is recorded.
Verification (every phase)
Use the preview workflow, not manual hand-off: start the dev server, exercise the
public wiki and the admin editor, check console/network for errors, and capture a
screenshot of the changed surface. Confirm npm run lint/build passes for the client
and the server boots cleanly with ensureSchema() applying the migration.
9. File-change map (reference)
| Area | Files |
|---|---|
| Schema/seed | server/db/schema.sql, server/db/seed.js, server/src/utils/db.js (ensureSchema) |
| Models | server/src/model/wiki/wiki.db.js, wiki.model.js, new wiki.links.js |
| API | server/src/router/v1/public/public.{routes,controller}.js, server/src/router/v1/admin/admin.{routes,controller}.js |
| Sanitize | new server/src/utils/sanitizeHtml.js |
| Client API | client/src/api/client.js |
| Public UI | client/src/routes/wiki/Wiki.jsx, WikiArticle.jsx |
| Admin UI | client/src/routes/admin/views/WikiAdmin.jsx, WikiEditor.jsx, new category manager + revisions view |
| Deps | client/package.json, server/package.json |
10. Open questions / assumptions
- Slug redirects: assumed not needed on rename (staff wiki). Revisit if pages get external inbound links.
- Search ranking: FULLTEXT natural-language mode assumed; can switch to BOOLEAN mode if operators are wanted later.
- Diff granularity: line/word diff of the HTML source is assumed sufficient for revision compare; a rendered visual diff is a later nice-to-have.
- Editor scope: tables and embeds beyond images are deferred unless requested.