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:
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user