From d2ff47029c108f5b01d5c619e25c3c7fed9f3faf Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 17 May 2026 02:03:28 +0800 Subject: [PATCH] fix(opds): detect XML feeds with leading whitespace, closes #4181 (#4190) OPDS responses were classified as XML vs JSON with `text.startsWith('<')`. Some servers (e.g. the Hungarian MEK catalog) return a valid Atom feed prefixed with newlines/whitespace before ``, no `` declaration, and a wrong `text/html` Content-Type. The naive check missed the `<`, so the XML body was handed to `JSON.parse`, failing with "Unexpected token '<' ... is not valid JSON". Add a shared `looksLikeXMLContent()` helper that trims leading whitespace (also stripping a UTF-8 BOM) before the check, and use it in both `loadOPDS` and `validateOPDSURL`. Detection is now based purely on the body, so formally-valid feeds with a bad Content-Type work. Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/__tests__/app/opds/opds-utils.test.ts | 57 +++++++++++++++++++ apps/readest-app/src/app/opds/page.tsx | 3 +- .../src/app/opds/utils/opdsUtils.ts | 16 +++++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts index 8c1a7929..a532408f 100644 --- a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts +++ b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts @@ -49,6 +49,7 @@ import { isSearchLink, resolveURL, getFileExtFromPath, + looksLikeXMLContent, MIME, validateOPDSURL, } from '@/app/opds/utils/opdsUtils'; @@ -354,6 +355,46 @@ describe('opdsUtils', () => { }); }); + describe('looksLikeXMLContent', () => { + it('detects XML that starts with the root element directly', () => { + expect(looksLikeXMLContent('')).toBe(true); + }); + + it('detects XML with an declaration', () => { + expect(looksLikeXMLContent('')).toBe(true); + }); + + // Regression for https://github.com/readest/readest/issues/4181 + // The Hungarian MEK catalog returns XML feeds with leading whitespace and + // newlines before (and no declaration). A naive + // text.startsWith('<') check misfired, sending the body to JSON.parse. + it('detects XML with leading whitespace and newlines', () => { + expect(looksLikeXMLContent(' \n ')).toBe( + true, + ); + }); + + it('detects XML with a leading UTF-8 BOM', () => { + expect(looksLikeXMLContent('')).toBe(true); + }); + + it('returns false for JSON content', () => { + expect(looksLikeXMLContent('{"metadata":{}}')).toBe(false); + }); + + it('returns false for JSON with leading whitespace', () => { + expect(looksLikeXMLContent(' \n {"metadata":{}}')).toBe(false); + }); + + it('returns false for plain text', () => { + expect(looksLikeXMLContent('Just some plain text')).toBe(false); + }); + + it('returns false for an empty string', () => { + expect(looksLikeXMLContent('')).toBe(false); + }); + }); + describe('validateOPDSURL', () => { beforeEach(() => { mockFetchWithAuth.mockReset(); @@ -522,6 +563,22 @@ describe('opdsUtils', () => { expect(result.isValid).toBe(true); }); + // Regression for https://github.com/readest/readest/issues/4181 + it('should accept an XML feed with leading whitespace and a text/html Content-Type', async () => { + const xmlFeed = ` \n\n MEK\n`; + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://bookserver.mek.oszk.hu/all/epub/0', + text: () => Promise.resolve(xmlFeed), + headers: new Headers({ 'Content-Type': 'text/html' }), + } as Response); + + const result = await validateOPDSURL('https://bookserver.mek.oszk.hu/all/epub/0'); + expect(result.isValid).toBe(true); + expect(result.data?.type).toBe('feed'); + }); + it('should return invalid for XML that is not a recognized OPDS document type', async () => { const xmlDoc = ` diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx index 3013a62f..ce665f60 100644 --- a/apps/readest-app/src/app/opds/page.tsx +++ b/apps/readest-app/src/app/opds/page.tsx @@ -26,6 +26,7 @@ import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds'; import { getFileExtFromPath, isSearchLink, + looksLikeXMLContent, MIME, parseMediaType, resolveURL, @@ -216,7 +217,7 @@ export default function BrowserPage() { const responseURL = res.url; const text = await res.text(); - if (text.startsWith('<')) { + if (looksLikeXMLContent(text)) { const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType); const { documentElement: { localName }, diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts index 4717fe28..40d2e907 100644 --- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts +++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts @@ -68,6 +68,20 @@ export const parseMediaType = (str?: string) => { }; }; +/** + * Detect whether an OPDS response body is XML rather than JSON. + * + * Some OPDS servers (e.g. the Hungarian MEK catalog, issue #4181) return XML + * feeds with leading whitespace/newlines before the root element — sometimes + * without an `` declaration — and a wrong `text/html` Content-Type. A + * naive `text.startsWith('<')` check then misfires and the body is handed to + * JSON.parse, producing "Unexpected token '<' ... is not valid JSON". + * + * Trimming leading whitespace (which also strips a UTF-8 BOM) before the + * check makes detection robust regardless of Content-Type. + */ +export const looksLikeXMLContent = (text: string): boolean => text.trimStart().startsWith('<'); + export const isSearchLink = (link: OPDSBaseLink): boolean => { const rels = Array.isArray(link.rel) ? link.rel : [link.rel || '']; return rels.includes('search') && (link.type === MIME.OPENSEARCH || link.type === MIME.ATOM); @@ -131,7 +145,7 @@ export const validateOPDSURL = async ( const text = await res.text(); // Check if it's XML-based OPDS - if (text.startsWith('<')) { + if (looksLikeXMLContent(text)) { const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType); const { documentElement: { localName },