bd3ef269d4
Remediates the rfc-app application + deploy-config findings from the Session 0026 security audit. Cut as the "v0.25.0-security-hardening" branch (from v0.24.0); reversioned to 0.27.0 on rebase onto main since v0.26.0 (#28) shipped while this was in flight. - C1 (Critical): single sanitizeHtml.js chokepoint (DOMPurify) for every marked→innerHTML / dangerouslySetInnerHTML sink (MarkdownPreview, ProposalView x2, Editor); rel=noopener hook on target=_blank links. - H1: per-account OTC-verify lockout (migration 023, auto-applied) + per-IP throttle via new ratelimit.py; wired on otc verify/request + passcode check/verify. - M1: device_trust.lookup() single indexed-row read — cookie value is now "<row_id>.<raw_token>"; bcrypt-checks one row, not a global table scan. (Behavior change: existing device-trust cookies re-prompt once.) - M2: HTTP security headers (CSP/HSTS/XFO/XCTO/Referrer-Policy) at nginx. - M4: session cookie Secure-by-default (SESSION_COOKIE_SECURE opt-out). - M5: bounce webhook fails CLOSED (503) when secret unset, instead of open; RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 dev opt-in. - L2/L3: per-IP cooldown + check-endpoint throttle. - L4: systemd sandbox knobs. L8/I1: nginx server_tokens off + TLS1.0/1.1 out. VERSION + frontend/package.json → 0.27.0; CHANGELOG documents the upgrade steps (incl. the out-of-band nginx + systemd apply, which the flotilla deploy gesture does not perform). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
4.3 KiB
React
135 lines
4.3 KiB
React
// Editor.jsx — the Discuss-mode read-only renderer (and main/no-contribute
|
|
// fallback). Tiptap on ProseMirror per §18; the Contribute-mode editing
|
|
// surface moved to MarkdownSourceEditor (CodeMirror 6) in the Phase 1
|
|
// rewrite of Contribute, leaving this surface for read-only rendering
|
|
// plus the §8.12 selection tooltip's coords.
|
|
//
|
|
// One ProseMirror plugin still lives alongside StarterKit:
|
|
//
|
|
// • selectionHighlight — keeps a selected passage highlighted while
|
|
// focus moves to the §8.12 selection tooltip. Driven by meta
|
|
// transactions dispatched from the parent.
|
|
//
|
|
// The paragraph-margin gutter accent that used to live here is dropped;
|
|
// Phase 4 of the Contribute rewrite adds a proper change-anchored gutter
|
|
// against the CodeMirror raw pane.
|
|
|
|
import { useEditor, EditorContent, Extension } from '@tiptap/react'
|
|
import StarterKit from '@tiptap/starter-kit'
|
|
import { useEffect, useRef, useCallback } from 'react'
|
|
import { renderMarkdown } from '../lib/sanitizeHtml'
|
|
import { Plugin, PluginKey } from 'prosemirror-state'
|
|
import { Decoration, DecorationSet } from 'prosemirror-view'
|
|
|
|
// ── Selection highlight plugin ────────────────────────────────────────────
|
|
|
|
export const selectionHighlightKey = new PluginKey('selectionHighlight')
|
|
|
|
function makeSelectionHighlightPlugin() {
|
|
return new Plugin({
|
|
key: selectionHighlightKey,
|
|
state: {
|
|
init: () => null,
|
|
apply(tr, prev) {
|
|
const meta = tr.getMeta(selectionHighlightKey)
|
|
return meta !== undefined ? meta : prev
|
|
},
|
|
},
|
|
props: {
|
|
decorations(state) {
|
|
const range = selectionHighlightKey.getState(state)
|
|
if (!range || range.from >= range.to) return DecorationSet.empty
|
|
try {
|
|
return DecorationSet.create(state.doc, [
|
|
Decoration.inline(range.from, range.to, { class: 'selection-highlight' }),
|
|
])
|
|
} catch {
|
|
return DecorationSet.empty
|
|
}
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
function SelectionHighlightExtension() {
|
|
return Extension.create({
|
|
name: 'selectionHighlight',
|
|
addProseMirrorPlugins() {
|
|
return [makeSelectionHighlightPlugin()]
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Editor component ──────────────────────────────────────────────────────
|
|
|
|
export default function Editor({
|
|
content,
|
|
editorRef,
|
|
onSelectionChange,
|
|
onUpdate,
|
|
editable = true,
|
|
}) {
|
|
const isMouseDownRef = useRef(false)
|
|
|
|
const reportSelection = useCallback((editor) => {
|
|
if (isMouseDownRef.current) return
|
|
const { from, to } = editor.state.selection
|
|
if (from !== to) {
|
|
const text = editor.state.doc.textBetween(from, to, ' ')
|
|
const coords = editor.view.coordsAtPos(from)
|
|
onSelectionChange?.({ text, coords, from, to })
|
|
} else {
|
|
onSelectionChange?.(null)
|
|
}
|
|
}, [onSelectionChange])
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit,
|
|
SelectionHighlightExtension(),
|
|
],
|
|
content: '<p></p>',
|
|
editable,
|
|
onUpdate: ({ editor }) => {
|
|
onUpdate?.(editor.getText(), editor.getHTML())
|
|
},
|
|
onSelectionUpdate: ({ editor }) => {
|
|
reportSelection(editor)
|
|
},
|
|
})
|
|
|
|
// Expose editor instance to the parent.
|
|
useEffect(() => {
|
|
if (editorRef) editorRef.current = editor
|
|
}, [editor, editorRef])
|
|
|
|
useEffect(() => {
|
|
if (editor) editor.setEditable(editable)
|
|
}, [editor, editable])
|
|
|
|
useEffect(() => {
|
|
if (!editor) return
|
|
const el = editor.view.dom
|
|
const onMouseDown = () => { isMouseDownRef.current = true; onSelectionChange?.(null) }
|
|
const onMouseUp = () => { isMouseDownRef.current = false; reportSelection(editor) }
|
|
el.addEventListener('mousedown', onMouseDown)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
return () => {
|
|
el.removeEventListener('mousedown', onMouseDown)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
}
|
|
}, [editor, onSelectionChange, reportSelection])
|
|
|
|
useEffect(() => {
|
|
if (!editor || content == null) return
|
|
const html = renderMarkdown(content)
|
|
editor.commands.setContent(html, false)
|
|
}, [content, editor])
|
|
|
|
return (
|
|
<div className="editor-wrapper">
|
|
<EditorContent editor={editor} className="editor-content" />
|
|
</div>
|
|
)
|
|
}
|