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:
2026-06-30 13:14:54 -05:00
parent 75b13159d7
commit a5a8c1930c
6 changed files with 109 additions and 50 deletions

View File

@@ -25,7 +25,13 @@ function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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>

View File

@@ -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 }) {
)}
</label>
<label>
<span className="field-label">Body (HTML or text)</span>
<textarea value={form.body} onChange={set('body')} className="textarea" />
</label>
<div>
<span className="field-label">Body</span>
<Suspense fallback={<span className="spin" />}>
<RichTextEditor
value={form.body}
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
variant={isScreenshot ? 'minimal' : 'post'}
/>
</Suspense>
</div>
</div>
</Modal>
)

View File

@@ -1,3 +1,4 @@
import DOMPurify from 'dompurify'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
@@ -36,7 +37,7 @@ export default function FiveOnFriday() {
{it.title}
</h2>
{it.body ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: it.body }} />
<div className="prose" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(it.body) }} />
) : (
it.excerpt && <p style={{ margin: 0, color: 'var(--text)' }}>{it.excerpt}</p>
)}

View File

@@ -1,4 +1,5 @@
import { Link, useParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
@@ -52,7 +53,7 @@ function Issue({ issue }) {
{issue.excerpt && <p style={{ margin: '14px 0 0', color: 'var(--muted)', fontSize: '1.1rem' }}>{issue.excerpt}</p>}
<div style={{ height: 1, background: 'var(--line)', margin: '28px 0' }} />
{issue.body ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: issue.body }} />
<div className="prose" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(issue.body) }} />
) : (
<p className="muted">This issue has no content yet.</p>
)}