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 `<feed>`, no `<?xml?>`
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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-17 02:03:28 +08:00
committed by GitHub
parent 40b7c2c15e
commit d2ff47029c
3 changed files with 74 additions and 2 deletions
@@ -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('<feed xmlns="http://www.w3.org/2005/Atom"></feed>')).toBe(true);
});
it('detects XML with an <?xml ?> declaration', () => {
expect(looksLikeXMLContent('<?xml version="1.0"?><feed></feed>')).toBe(true);
});
// Regression for https://github.com/readest/readest/issues/4181
// The Hungarian MEK catalog returns XML feeds with leading whitespace and
// newlines before <feed> (and no <?xml ?> declaration). A naive
// text.startsWith('<') check misfired, sending the body to JSON.parse.
it('detects XML with leading whitespace and newlines', () => {
expect(looksLikeXMLContent(' \n <feed xmlns="http://www.w3.org/2005/Atom"></feed>')).toBe(
true,
);
});
it('detects XML with a leading UTF-8 BOM', () => {
expect(looksLikeXMLContent('<feed></feed>')).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<feed xmlns="http://www.w3.org/2005/Atom">\n <title>MEK</title>\n</feed>`;
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 = `<?xml version="1.0" encoding="UTF-8"?>
<catalog>
+2 -1
View File
@@ -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 },
@@ -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 `<?xml ?>` 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 },