From 8425d0b91f8177dbc2cee640d701e2548c8cbf35 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 10 Jun 2026 12:52:34 +0800 Subject: [PATCH] fix(opds): render HTML in publication descriptions (#4510) * fix(opds): render HTML in publication descriptions, closes #4503 OPDS publication descriptions showed raw HTML tags (literal `

`, `"`, `'`) instead of rendering them. Some aggregator feeds serve the description as an Atom `type="text"` summary whose HTML has been escaped twice; foliate's getContent only un-escapes `type="html"`/ `"xhtml"`, so the markup survives parsing as entity text and the detail view dumped it straight into an unsanitized `dangerouslySetInnerHTML` (also an XSS sink for untrusted feed content). Add `getOPDSDescriptionHtml`: decode one extra entity level only when the value is entirely escaped markup (mixed content like `

see <code>` is left literal), then sanitize with the shared DOMPurify sanitizer. Wire it into PublicationView and render the sanitized HTML. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: consolidate HTML sanitizers into @/utils/sanitize sanitizeHtml/sanitizeForParsing are generic DOMPurify wrappers, not specific to Send-to-Readest. Now that OPDS description rendering also needs sanitizeHtml, move them out of services/send/conversion into the shared @/utils/sanitize module (alongside sanitizeString) so neither consumer reaches across the other's feature boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/app/opds/opds-content.test.ts | 117 ++++++++++++++++++ .../services/send-conversion.test.ts | 2 +- .../app/opds/components/PublicationView.tsx | 12 +- .../src/app/opds/utils/opdsContent.ts | 38 ++++++ .../services/send/conversion/convertToEpub.ts | 2 +- .../services/send/conversion/sanitizeHtml.ts | 72 ----------- apps/readest-app/src/types/opds.ts | 2 +- apps/readest-app/src/utils/sanitize.ts | 73 +++++++++++ 8 files changed, 236 insertions(+), 82 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/opds/opds-content.test.ts create mode 100644 apps/readest-app/src/app/opds/utils/opdsContent.ts delete mode 100644 apps/readest-app/src/services/send/conversion/sanitizeHtml.ts diff --git a/apps/readest-app/src/__tests__/app/opds/opds-content.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-content.test.ts new file mode 100644 index 00000000..ea902567 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/opds/opds-content.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { getPublication } from 'foliate-js/opds.js'; +import { getOPDSDescriptionHtml } from '@/app/opds/utils/opdsContent'; +import { SYMBOL, type OPDSPublication } from '@/types/opds'; + +// Render an HTML string the way `dangerouslySetInnerHTML` would and return the +// visible text, so a test can tell "renders as markup" from "shows raw tags". +const renderedText = (html: string): string => { + const el = document.createElement('div'); + el.innerHTML = html; + return el.textContent ?? ''; +}; + +const parsePublication = (entryInner: string): OPDSPublication => { + const xml = `T${entryInner}`; + const doc = new DOMParser().parseFromString(xml, 'application/xml'); + return getPublication(doc.documentElement) as OPDSPublication; +}; + +describe('getOPDSDescriptionHtml', () => { + it('returns empty string for missing content', () => { + expect(getOPDSDescriptionHtml(undefined)).toBe(''); + expect(getOPDSDescriptionHtml({ value: '', type: 'text' })).toBe(''); + }); + + it('renders real (single-escaped) HTML in a text summary as markup', () => { + //

<p>Hi &quot;q&quot;</p> + const content = { value: '

Hi "q"

', type: 'text' as const }; + const html = getOPDSDescriptionHtml(content); + expect(html).toContain('

'); + expect(renderedText(html)).toBe('Hi "q"'); + }); + + it('renders HTML for type="html" content', () => { + const content = { value: '

Hello world

', type: 'html' as const }; + const html = getOPDSDescriptionHtml(content); + expect(html).toContain(''); + expect(renderedText(html)).toBe('Hello world'); + }); + + it('renders xhtml content and unwraps the namespaced wrapper div', () => { + const content = { + value: '

Hi

', + type: 'xhtml' as const, + }; + const html = getOPDSDescriptionHtml(content); + expect(renderedText(html)).toBe('Hi'); + }); + + // The bug: issue #4503. An aggregator feed serves the description as a + // type="text" summary whose HTML has been escaped twice, so it survives + // parsing as entity *text* ("<p>...") and showed literal tags. + it('decodes double-escaped HTML so it renders instead of showing raw tags', () => { + const content = { + value: + '<p>Creators</p><p>&quot;Wall&quot; Sollenar&#x27;s</p>', + type: 'text' as const, + }; + const html = getOPDSDescriptionHtml(content); + // No literal tag/entity text should remain visible to the user. + const text = renderedText(html); + expect(text).not.toContain('

'); + expect(text).not.toContain('"'); + expect(text).not.toContain('''); + expect(text).toContain('Creators'); + expect(text).toContain('"Wall"'); + expect(text).toContain("Sollenar's"); + // The markup itself contains real paragraph elements. + expect(html).toContain('

'); + }); + + it('end-to-end: a double-escaped Atom entry (issue #4503) renders as HTML', () => { + const pub = parsePublication( + '

&lt;p&gt;Creators: Algis Budrys&lt;/p&gt;' + + '&lt;p&gt;&amp;quot;Wall of Crystal&amp;quot; by Budrys&lt;/p&gt;', + ); + const content = pub.metadata[SYMBOL.CONTENT]; + const html = getOPDSDescriptionHtml(content); + const text = renderedText(html); + expect(text).not.toContain('

'); + expect(text).toContain('Creators: Algis Budrys'); + expect(text).toContain('"Wall of Crystal"'); + expect(html).toContain('

'); + }); + + it('leaves mixed real-and-escaped markup untouched (no over-decoding)', () => { + // Author intentionally shows a literal tag inside a real paragraph. + const content = { value: '

Use <code> here

', type: 'text' as const }; + const text = renderedText(getOPDSDescriptionHtml(content)); + expect(text).toBe('Use here'); + }); + + it('strips scripts from untrusted feed HTML', () => { + const content = { + value: '

Hi

', + type: 'html' as const, + }; + const html = getOPDSDescriptionHtml(content); + expect(html).not.toContain('