From a5a8c1930c1aeb57b375ad2996484cc820bbad4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 13:14:54 -0500 Subject: [PATCH] RTE Posts upgrade: TipTap editor + sanitization for posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `

` 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 --- client/src/components/RichTextEditor.jsx | 92 +++++++++++--------- client/src/routes/admin/views/PostEditor.jsx | 18 ++-- client/src/routes/public/FiveOnFriday.jsx | 3 +- client/src/routes/public/NewsletterIssue.jsx | 3 +- server/src/model/posts/posts.model.js | 23 ++++- server/src/utils/sanitizeHtml.js | 20 ++++- 6 files changed, 109 insertions(+), 50 deletions(-) diff --git a/client/src/components/RichTextEditor.jsx b/client/src/components/RichTextEditor.jsx index e9289f4..0f81773 100644 --- a/client/src/components/RichTextEditor.jsx +++ b/client/src/components/RichTextEditor.jsx @@ -25,7 +25,13 @@ function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) } -export default function RichTextEditor({ value, onChange, pages = [] }) { +// Toolbar variants: +// 'full' — every control, incl. the internal wiki-page link picker (wiki use). +// 'post' — full minus the wiki-page picker (no page-list context in posts). +// 'minimal' — image upload only; text formatting stripped (Screenshots captions). +export default function RichTextEditor({ value, onChange, pages = [], variant = 'full' }) { + const showText = variant !== 'minimal' // bold/italic/strike, headings, lists, quotes, links + const showWikiLink = variant === 'full' && pages.length > 0 const fileRef = useRef(null) const [uploading, setUploading] = useState(false) const [linkMenu, setLinkMenu] = useState(false) @@ -90,45 +96,51 @@ export default function RichTextEditor({ value, onChange, pages = [] }) { return (
- editor.chain().focus().toggleBold().run()}> - B - - editor.chain().focus().toggleItalic().run()}> - I - - editor.chain().focus().toggleStrike().run()}> - S - - - editor.chain().focus().toggleHeading({ level: 2 }).run()}> - H2 - - editor.chain().focus().toggleHeading({ level: 3 }).run()}> - H3 - - - editor.chain().focus().toggleBulletList().run()}> - • List - - editor.chain().focus().toggleOrderedList().run()}> - 1. List - - editor.chain().focus().toggleBlockquote().run()}> - ❝ - - editor.chain().focus().toggleCodeBlock().run()}> - {''} - - editor.chain().focus().setHorizontalRule().run()}> - — - - - - 🔗 - - setLinkMenu((v) => !v)}> - 📄 - + {showText && ( + <> + editor.chain().focus().toggleBold().run()}> + B + + editor.chain().focus().toggleItalic().run()}> + I + + editor.chain().focus().toggleStrike().run()}> + S + + + editor.chain().focus().toggleHeading({ level: 2 }).run()}> + H2 + + editor.chain().focus().toggleHeading({ level: 3 }).run()}> + H3 + + + editor.chain().focus().toggleBulletList().run()}> + • List + + editor.chain().focus().toggleOrderedList().run()}> + 1. List + + editor.chain().focus().toggleBlockquote().run()}> + ❝ + + editor.chain().focus().toggleCodeBlock().run()}> + {''} + + editor.chain().focus().setHorizontalRule().run()}> + — + + + + 🔗 + + {showWikiLink && ( + setLinkMenu((v) => !v)}> + 📄 + + )} + + )} fileRef.current?.click()}> {uploading ? '…' : '🖼'} diff --git a/client/src/routes/admin/views/PostEditor.jsx b/client/src/routes/admin/views/PostEditor.jsx index 5d1df24..5747b45 100644 --- a/client/src/routes/admin/views/PostEditor.jsx +++ b/client/src/routes/admin/views/PostEditor.jsx @@ -1,7 +1,9 @@ -import { useState } from 'react' +import { lazy, Suspense, useState } from 'react' import Modal from '../../../components/Modal.jsx' import { api } from '../../../api/client.js' +const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx')) + const CATEGORIES = [ { v: 'news', l: 'News' }, { v: 'five-on-friday', l: 'Five on Friday' }, @@ -146,10 +148,16 @@ export default function PostEditor({ post, onClose, onSaved }) { )} -