RTE Posts upgrade: TipTap rich-text editing + sanitization for posts #7
Reference in New Issue
Block a user
No description provided.
Delete Branch "rte-posts-upgrade"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.docxspec by reusing the existing wiki components andsanitization helper rather than introducing anything new.
No schema migrations. No new npm packages (TipTap + DOMPurify +
sanitize-htmlwere already in the tree from the wiki upgrade).
Why
FiveOnFriday.jsxandNewsletterIssue.jsxrendereddangerouslySetInnerHTML={{ __html: body }}with no sanitization, and postbodies were stored exactly as submitted.
<textarea>, so staff hadto hand-write HTML for any formatting and could not embed inline images.
stripHtml()over the wholebody, which could grab boilerplate.
Changes
Backend
server/src/utils/sanitizeHtml.js— addderiveExcerpt(html, max=280):strips tags, collapses whitespace, truncates with an ellipsis. Reuses the
existing wiki
cleanBodyallowlist (no new allowlist).server/src/model/posts/posts.model.js— oncreate/update:cleanBody;<p></p>) asnullso we never store a meaninglessempty paragraph;
blank (an explicitly-provided excerpt is preserved).
wiki.model.js; the admin controller neededno change.
Admin editor
client/src/components/RichTextEditor.jsx— add avariantprop: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), forScreenshot captions.
showText/showWikiLinkflags — no component duplication.
client/src/routes/admin/views/PostEditor.jsx— replace the body<textarea>with a lazily-importedRichTextEditorinside<Suspense>(mirrors the WikiEditor pattern). Variant is chosen by category:
minimalfor Screenshots,postotherwise.Public render (defense in depth)
client/src/routes/public/FiveOnFriday.jsxandclient/src/routes/public/NewsletterIssue.jsx— wrapdangerouslySetInnerHTMLwithDOMPurify.sanitize(), matchingWikiArticle.(
News.jsxis text-only viastripHtmland 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/:iddetail page.Verification
Ran end-to-end against the local stack (MariaDB + API + Vite):
<script>,onerror=, andjavascript:hrefs stripped while<h2>ispreserved; excerpt auto-derived from body; empty
<p></p>→null;Screenshot-without-image → 400; an explicit excerpt is not overwritten;
public endpoints return sanitized bodies.
postvariant shows the fulltoolbar minus 📄;
minimalvariant collapses to image/undo/redo; a fullcreate round-trip through the editor persisted the body and auto-excerpt.
no
<script>/onerror;javascript:href stripped).vite buildpasses;RichTextEditorsplits into its own lazy chunk.Files changed (6)
client/src/components/RichTextEditor.jsxvariantprop + conditional toolbarclient/src/routes/admin/views/PostEditor.jsxclient/src/routes/public/FiveOnFriday.jsxclient/src/routes/public/NewsletterIssue.jsxserver/src/model/posts/posts.model.jsserver/src/utils/sanitizeHtml.jsderiveExcerpt()helperRisk
Low. Existing hand-typed/plain-text bodies pass through the allowlist
unchanged; only
<script>/event-handler attributes are stripped (the intendedoutcome). No schema or dependency changes.
It works correctly