RTE Posts upgrade: TipTap rich-text editing + sanitization for posts #7

Merged
whitlocktech merged 2 commits from rte-posts-upgrade into main 2026-06-30 18:34:04 +00:00
Member

Summary

Brings the wiki's TipTap rich-text editor to the Posts editor (News,
Five on Friday, Newsletter, Screenshots) and closes a stored-XSS gap
where post bodies were rendered as raw, unsanitized HTML. Implements the
RTE_Posts_Design.docx spec by reusing the existing wiki components and
sanitization helper
rather than introducing anything new.

No schema migrations. No new npm packages (TipTap + DOMPurify + sanitize-html
were already in the tree from the wiki upgrade).

Why

  • Stored XSS: FiveOnFriday.jsx and NewsletterIssue.jsx rendered
    dangerouslySetInnerHTML={{ __html: body }} with no sanitization, and post
    bodies were stored exactly as submitted.
  • No rich authoring: the Posts editor was a raw <textarea>, so staff had
    to hand-write HTML for any formatting and could not embed inline images.
  • Excerpt quality: blank excerpts fell back to stripHtml() over the whole
    body, which could grab boilerplate.

Changes

Backend

  • server/src/utils/sanitizeHtml.js — add deriveExcerpt(html, max=280):
    strips tags, collapses whitespace, truncates with an ellipsis. Reuses the
    existing wiki cleanBody allowlist (no new allowlist).
  • server/src/model/posts/posts.model.js — on create/update:
    • sanitize the body through cleanBody;
    • treat an empty editor (<p></p>) as null so we never store a meaningless
      empty paragraph;
    • auto-derive the excerpt from the sanitized body when the excerpt field is
      blank (an explicitly-provided excerpt is preserved).
    • Placed in the model to mirror wiki.model.js; the admin controller needed
      no change.

Admin editor

  • client/src/components/RichTextEditor.jsx — add a variant prop:
    • full (default) — the wiki toolbar, unchanged;
    • post — full toolbar minus the internal wiki-page link picker (📄),
      since posts have no page-list context;
    • minimal — image upload + undo/redo only (text formatting stripped), for
      Screenshot captions.
    • Implemented by gating toolbar sections with showText / showWikiLink
      flags — no component duplication.
  • client/src/routes/admin/views/PostEditor.jsx — replace the body
    <textarea> with a lazily-imported RichTextEditor inside <Suspense>
    (mirrors the WikiEditor pattern). Variant is chosen by category:
    minimal for Screenshots, post otherwise.

Public render (defense in depth)

  • client/src/routes/public/FiveOnFriday.jsx and
    client/src/routes/public/NewsletterIssue.jsx — wrap
    dangerouslySetInnerHTML with DOMPurify.sanitize(), matching WikiArticle.
    (News.jsx is text-only via stripHtml and is unchanged.)

Out of scope (per the design doc)

Internal-link picker for posts, post revision history, full-text search across
post bodies, a public caption render on the Screenshots page, and a
/site/news/:id detail page.

Verification

Ran end-to-end against the local stack (MariaDB + API + Vite):

  • API — 24/24 assertions passed: all four categories create/update;
    <script>, onerror=, and javascript: hrefs stripped while <h2> is
    preserved; excerpt auto-derived from body; empty <p></p>null;
    Screenshot-without-image → 400; an explicit excerpt is not overwritten;
    public endpoints return sanitized bodies.
  • UI: RichTextEditor mounts in PostEditor; post variant shows the full
    toolbar minus 📄; minimal variant collapses to image/undo/redo; a full
    create round-trip through the editor persisted the body and auto-excerpt.
  • Public render: the Five-on-Friday DOM renders sanitized (heading kept;
    no <script>/onerror; javascript: href stripped).
  • vite build passes; RichTextEditor splits into its own lazy chunk.

Files changed (6)

File Change
client/src/components/RichTextEditor.jsx variant prop + conditional toolbar
client/src/routes/admin/views/PostEditor.jsx lazy RTE replaces textarea, variant by category
client/src/routes/public/FiveOnFriday.jsx DOMPurify on render
client/src/routes/public/NewsletterIssue.jsx DOMPurify on render
server/src/model/posts/posts.model.js sanitize + excerpt + empty-body handling
server/src/utils/sanitizeHtml.js deriveExcerpt() helper

Risk

Low. Existing hand-typed/plain-text bodies pass through the allowlist
unchanged; only <script>/event-handler attributes are stripped (the intended
outcome). No schema or dependency changes.

## Summary Brings the wiki's TipTap rich-text editor to the **Posts** editor (News, Five on Friday, Newsletter, Screenshots) and closes a **stored-XSS gap** where post bodies were rendered as raw, unsanitized HTML. Implements the `RTE_Posts_Design.docx` spec by **reusing the existing wiki components and sanitization helper** rather than introducing anything new. No schema migrations. No new npm packages (TipTap + DOMPurify + `sanitize-html` were already in the tree from the wiki upgrade). ## Why - **Stored XSS:** `FiveOnFriday.jsx` and `NewsletterIssue.jsx` rendered `dangerouslySetInnerHTML={{ __html: body }}` with no sanitization, and post bodies were stored exactly as submitted. - **No rich authoring:** the Posts editor was a raw `<textarea>`, so staff had to hand-write HTML for any formatting and could not embed inline images. - **Excerpt quality:** blank excerpts fell back to `stripHtml()` over the whole body, which could grab boilerplate. ## Changes ### Backend - **`server/src/utils/sanitizeHtml.js`** — add `deriveExcerpt(html, max=280)`: strips tags, collapses whitespace, truncates with an ellipsis. Reuses the existing wiki `cleanBody` allowlist (no new allowlist). - **`server/src/model/posts/posts.model.js`** — on `create`/`update`: - sanitize the body through `cleanBody`; - treat an empty editor (`<p></p>`) as `null` so we never store a meaningless empty paragraph; - auto-derive the excerpt from the sanitized body when the excerpt field is blank (an explicitly-provided excerpt is preserved). - Placed in the model to mirror `wiki.model.js`; the admin controller needed no change. ### Admin editor - **`client/src/components/RichTextEditor.jsx`** — add a `variant` prop: - `full` (default) — the wiki toolbar, unchanged; - `post` — full toolbar **minus** the internal wiki-page link picker (📄), since posts have no page-list context; - `minimal` — image upload + undo/redo only (text formatting stripped), for Screenshot captions. - Implemented by gating toolbar sections with `showText` / `showWikiLink` flags — no component duplication. - **`client/src/routes/admin/views/PostEditor.jsx`** — replace the body `<textarea>` with a lazily-imported `RichTextEditor` inside `<Suspense>` (mirrors the WikiEditor pattern). Variant is chosen by category: `minimal` for Screenshots, `post` otherwise. ### Public render (defense in depth) - **`client/src/routes/public/FiveOnFriday.jsx`** and **`client/src/routes/public/NewsletterIssue.jsx`** — wrap `dangerouslySetInnerHTML` with `DOMPurify.sanitize()`, matching `WikiArticle`. (`News.jsx` is text-only via `stripHtml` and is unchanged.) ## Out of scope (per the design doc) Internal-link picker for posts, post revision history, full-text search across post bodies, a public caption render on the Screenshots page, and a `/site/news/:id` detail page. ## Verification Ran end-to-end against the local stack (MariaDB + API + Vite): - **API — 24/24 assertions passed:** all four categories create/update; `<script>`, `onerror=`, and `javascript:` hrefs stripped while `<h2>` is preserved; excerpt auto-derived from body; empty `<p></p>` → `null`; Screenshot-without-image → 400; an explicit excerpt is not overwritten; public endpoints return sanitized bodies. - **UI:** RichTextEditor mounts in PostEditor; `post` variant shows the full toolbar minus 📄; `minimal` variant collapses to image/undo/redo; a full create round-trip through the editor persisted the body and auto-excerpt. - **Public render:** the Five-on-Friday DOM renders sanitized (heading kept; no `<script>`/`onerror`; `javascript:` href stripped). - `vite build` passes; `RichTextEditor` splits into its own lazy chunk. ## Files changed (6) | File | Change | | --- | --- | | `client/src/components/RichTextEditor.jsx` | `variant` prop + conditional toolbar | | `client/src/routes/admin/views/PostEditor.jsx` | lazy RTE replaces textarea, variant by category | | `client/src/routes/public/FiveOnFriday.jsx` | DOMPurify on render | | `client/src/routes/public/NewsletterIssue.jsx` | DOMPurify on render | | `server/src/model/posts/posts.model.js` | sanitize + excerpt + empty-body handling | | `server/src/utils/sanitizeHtml.js` | `deriveExcerpt()` helper | ## Risk Low. Existing hand-typed/plain-text bodies pass through the allowlist unchanged; only `<script>`/event-handler attributes are stripped (the intended outcome). No schema or dependency changes.
wtclaude added 1 commit 2026-06-30 18:31:38 +00:00
Extend the wiki's RichTextEditor to the Posts editor and close the
stored-XSS gap on public post bodies.

- RichTextEditor: add `variant` prop — `full` (wiki), `post` (no
  internal wiki-page link picker), `minimal` (image-only, for
  Screenshots captions). Toolbar sections rendered conditionally.
- PostEditor: replace the body textarea with a lazy-loaded
  RichTextEditor in Suspense; variant chosen by category
  (minimal for screenshots, post otherwise).
- posts.model: sanitize body via shared cleanBody on create/update,
  treat an empty TipTap `<p></p>` as null, and auto-derive the
  excerpt from the body (max 280 chars) when left blank.
- sanitizeHtml util: add deriveExcerpt() helper.
- FiveOnFriday / NewsletterIssue: wrap dangerouslySetInnerHTML with
  DOMPurify.sanitize() as defense-in-depth on render.

No schema or dependency changes. Verified end-to-end against the
local stack: 24/24 API assertions and a full UI round-trip across
all four post categories.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
whitlocktech added 1 commit 2026-06-30 18:33:22 +00:00
whitlocktech approved these changes 2026-06-30 18:33:48 +00:00
whitlocktech left a comment
Owner

It works correctly

It works correctly
whitlocktech merged commit 103007e49a into main 2026-06-30 18:34:04 +00:00
Sign in to join this conversation.
No description provided.