RTE Posts upgrade: TipTap editor + sanitization for posts
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>
This commit is contained in:
@@ -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 (
|
||||
<div className="rte">
|
||||
<div className="rte-toolbar">
|
||||
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<b>B</b>
|
||||
</Btn>
|
||||
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<i>I</i>
|
||||
</Btn>
|
||||
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<s>S</s>
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Heading 2 (table of contents)" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</Btn>
|
||||
<Btn title="Heading 3" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
• List
|
||||
</Btn>
|
||||
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
1. List
|
||||
</Btn>
|
||||
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
❝
|
||||
</Btn>
|
||||
<Btn title="Code block" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
||||
{'</>'}
|
||||
</Btn>
|
||||
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
|
||||
—
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
||||
🔗
|
||||
</Btn>
|
||||
<Btn title="Link to another wiki page" disabled={pages.length === 0} onClick={() => setLinkMenu((v) => !v)}>
|
||||
📄
|
||||
</Btn>
|
||||
{showText && (
|
||||
<>
|
||||
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<b>B</b>
|
||||
</Btn>
|
||||
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<i>I</i>
|
||||
</Btn>
|
||||
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<s>S</s>
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Heading 2 (table of contents)" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</Btn>
|
||||
<Btn title="Heading 3" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
• List
|
||||
</Btn>
|
||||
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
1. List
|
||||
</Btn>
|
||||
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
❝
|
||||
</Btn>
|
||||
<Btn title="Code block" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
||||
{'</>'}
|
||||
</Btn>
|
||||
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
|
||||
—
|
||||
</Btn>
|
||||
<span className="rte-sep" />
|
||||
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
||||
🔗
|
||||
</Btn>
|
||||
{showWikiLink && (
|
||||
<Btn title="Link to another wiki page" onClick={() => setLinkMenu((v) => !v)}>
|
||||
📄
|
||||
</Btn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Btn title="Insert image" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? '…' : '🖼'}
|
||||
</Btn>
|
||||
|
||||
Reference in New Issue
Block a user