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

@@ -1,4 +1,13 @@
const postsDb = require('./posts.db')
const { cleanBody, deriveExcerpt } = require('../../utils/sanitizeHtml')
// Sanitize body HTML and treat an empty editor (TipTap emits `<p></p>`) as null
// so we never store a meaningless empty paragraph.
function normalizeBody(body) {
const clean = cleanBody(body)
if (clean == null) return null
return String(clean).trim() === '<p></p>' ? null : clean
}
// URL category (kebab) <-> DB enum value.
const CATEGORY_MAP = {
@@ -40,12 +49,22 @@ async function getById(id) {
}
async function create(post) {
const id = await postsDb.insert(post)
const body = normalizeBody(post.body)
// Auto-fill the excerpt from the first of the body when left blank.
const excerpt = post.excerpt && String(post.excerpt).trim() ? post.excerpt : deriveExcerpt(body)
const id = await postsDb.insert({ ...post, body, excerpt: excerpt || null })
return postsDb.findById(id)
}
async function update(id, fields) {
await postsDb.update(id, fields)
const next = { ...fields }
if ('body' in next) next.body = normalizeBody(next.body)
// If the body is being updated and no non-empty excerpt was supplied,
// derive one from the new body.
if ('body' in next && !(next.excerpt && String(next.excerpt).trim())) {
next.excerpt = deriveExcerpt(next.body) || null
}
await postsDb.update(id, next)
return postsDb.findById(id)
}

View File

@@ -42,4 +42,22 @@ function cleanBody(html) {
return sanitizeHtml(String(html), OPTIONS)
}
module.exports = { cleanBody, OPTIONS }
/**
* Derive a plain-text excerpt from body HTML. Strips tags, collapses
* whitespace, and truncates to `max` chars (with an ellipsis). Used as the
* excerpt fallback when an author leaves the excerpt field blank.
* @param {string|null|undefined} html
* @param {number} [max=280]
* @returns {string|null}
*/
function deriveExcerpt(html, max = 280) {
if (html == null) return null
const text = String(html)
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 3)}...` : text
}
module.exports = { cleanBody, deriveExcerpt, OPTIONS }