diff --git a/src/lib/documents/markdown.ts b/src/lib/documents/markdown.ts index 2bbc7fd..982fcec 100644 --- a/src/lib/documents/markdown.ts +++ b/src/lib/documents/markdown.ts @@ -468,7 +468,7 @@ export function markdownToDoc(markdown: string): JSONContent { // Table: header row, | --- | separator, then body rows. if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) { flushParagraph(); - const parseRow = (row: string, header: boolean): JSONContent => ({ + const parseRow = (row: string, header: boolean): JSONContent => ({ type: 'tableRow', content: row .replace(/^\|/, '') diff --git a/src/lib/documents/sanitizeHtml.ts b/src/lib/documents/sanitizeHtml.ts index ec1d37e..484f24c 100644 --- a/src/lib/documents/sanitizeHtml.ts +++ b/src/lib/documents/sanitizeHtml.ts @@ -42,6 +42,13 @@ const ALLOWED_ELEMENTS = new Set([ /** class="task-list" / "task-item" and data-checked carry checklist state. */ const ALLOWED_CLASSES = new Set(['task-list', 'task-item']); +/** + * Elements whose entire subtree must be dropped rather than unwrapped: their + * text content is not safe to surface (e.g. `` would + * otherwise become a literal "alert(1)" text node). + */ +const REMOVE_ENTIRELY = new Set(['script', 'style']); + const SAFE_PROTOCOLS = new Set(['https:', 'http:', 'mailto:', 'nostr:']); function sanitizeHref(value: string | null): string | null { @@ -94,12 +101,19 @@ function sanitizeAttributes(element: Element): void { } function sanitizeElement(element: Element): void { + const tag = element.tagName.toLowerCase(); + if (REMOVE_ENTIRELY.has(tag)) { + // Drop the element and all of its content — unwrapping would surface + // script/style text as a literal text node. + element.remove(); + return; + } + // Children first: a disallowed child is unwrapped before we look up again. for (const child of [...element.children]) { sanitizeElement(child); } - const tag = element.tagName.toLowerCase(); if (!ALLOWED_ELEMENTS.has(tag)) { // Unwrap: keep the (already sanitized) children, drop the wrapper. This // keeps the text of a
or instead of deleting it. diff --git a/src/lib/documents/ydoc.ts b/src/lib/documents/ydoc.ts index 84ef0a1..eb9bb8d 100644 --- a/src/lib/documents/ydoc.ts +++ b/src/lib/documents/ydoc.ts @@ -28,6 +28,21 @@ function indexedDbAvailable(): boolean { } } +/** + * Constructing `IndexeddbPersistence` can throw even when IndexedDB exists + * (e.g. blocked/denied storage in private browsing or locked-down + * environments). Treat persistence as best-effort and degrade to `null` + * (in-memory) rather than crashing the caller. + */ +function createPersistence(documentId: string, doc: Y.Doc): IndexeddbPersistence | null { + if (!indexedDbAvailable()) return null; + try { + return new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc); + } catch { + return null; + } +} + /** * Opens the Yjs document for `documentId` with offline persistence behind it. * @@ -49,9 +64,7 @@ export function openDocumentSession( events: DocumentSessionEvents, ): DocumentSession { const doc = new Y.Doc(); - const persistence = indexedDbAvailable() - ? new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc) - : null; + const persistence = createPersistence(documentId, doc); const onUpdate = () => events.onUpdate(); doc.on('update', onUpdate); @@ -91,13 +104,16 @@ export function openDocumentSession( /** Removes the persisted body of a document that was deleted from the index. */ export async function deletePersistedDocument(documentId: string): Promise { - if (!indexedDbAvailable()) return; const doc = new Y.Doc(); - const persistence = new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc); try { - await persistence.clearData(); + const persistence = createPersistence(documentId, doc); + if (!persistence) return; + try { + await persistence.clearData(); + } finally { + persistence.destroy(); + } } finally { - persistence.destroy(); doc.destroy(); } }