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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user