From 45bd355981e8fe2dcaf0588dbaa3522151116ed9 Mon Sep 17 00:00:00 2001 From: Yi-Shun Wang <7034439+ShunnyBunny@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:01:16 -0400 Subject: [PATCH] feat(opds): support custom catalog headers with web proxy consent (#3740) * Add custom headers support for OPDS catalogs * Require web proxy consent for sensitive OPDS credentials * Strengthen OPDS header forwarding tests --------- Co-authored-by: Yi-Shun Wang --- .../src/__tests__/app/opds/opds-utils.test.ts | 9 +- .../utils/opds-custom-headers.test.ts | 51 +++++++ .../src/__tests__/utils/opds-req.test.ts | 17 ++- .../src/app/api/opds/proxy/route.ts | 20 ++- .../app/opds/components/CatelogManager.tsx | 143 ++++++++++++++++-- apps/readest-app/src/app/opds/page.tsx | 35 +++-- .../src/app/opds/utils/customHeaders.ts | 80 ++++++++++ .../readest-app/src/app/opds/utils/opdsReq.ts | 34 ++++- .../src/app/opds/utils/opdsUtils.ts | 14 +- apps/readest-app/src/types/opds.ts | 1 + 10 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 apps/readest-app/src/__tests__/utils/opds-custom-headers.test.ts create mode 100644 apps/readest-app/src/app/opds/utils/customHeaders.ts 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 5e61d2e6..c2a67d9b 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 @@ -546,8 +546,12 @@ describe('opdsUtils', () => { expect(result.isValid).toBe(false); }); - it('should pass username and password to fetchWithAuth', async () => { + it('should pass auth and custom headers to fetchWithAuth', async () => { const xmlFeed = `Auth Feed`; + const customHeaders = { + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'client-secret', + }; mockFetchWithAuth.mockResolvedValue({ ok: true, @@ -556,7 +560,7 @@ describe('opdsUtils', () => { headers: new Headers(), } as Response); - await validateOPDSURL('https://example.com/opds', 'user', 'pass', true); + await validateOPDSURL('https://example.com/opds', 'user', 'pass', true, customHeaders); expect(mockFetchWithAuth).toHaveBeenCalledWith( 'https://example.com/opds', @@ -564,6 +568,7 @@ describe('opdsUtils', () => { 'pass', true, expect.objectContaining({ signal: expect.anything() }), + customHeaders, ); }); }); diff --git a/apps/readest-app/src/__tests__/utils/opds-custom-headers.test.ts b/apps/readest-app/src/__tests__/utils/opds-custom-headers.test.ts new file mode 100644 index 00000000..822517ab --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/opds-custom-headers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + deserializeOPDSCustomHeaders, + formatOPDSCustomHeadersInput, + parseOPDSCustomHeadersInput, + serializeOPDSCustomHeaders, +} from '@/app/opds/utils/customHeaders'; + +describe('OPDS custom headers', () => { + it('parses multiline header input', () => { + const result = parseOPDSCustomHeadersInput(` + CF-Access-Client-Id: client-id + CF-Access-Client-Secret: secret:value + `); + + expect(result.error).toBeUndefined(); + expect(result.headers).toEqual({ + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'secret:value', + }); + }); + + it('reports malformed header lines', () => { + const result = parseOPDSCustomHeadersInput('missing separator'); + + expect(result.headers).toEqual({}); + expect(result.error).toContain('line 1'); + }); + + it('serializes and restores stored custom headers', () => { + const serialized = serializeOPDSCustomHeaders({ + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'secret', + }); + + expect(serialized).toBeTypeOf('string'); + expect(deserializeOPDSCustomHeaders(serialized)).toEqual({ + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'secret', + }); + }); + + it('formats saved headers for textarea editing', () => { + expect( + formatOPDSCustomHeadersInput({ + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'secret', + }), + ).toBe('CF-Access-Client-Id: client-id\nCF-Access-Client-Secret: secret'); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/opds-req.test.ts b/apps/readest-app/src/__tests__/utils/opds-req.test.ts index 9d5036f6..0df2a652 100644 --- a/apps/readest-app/src/__tests__/utils/opds-req.test.ts +++ b/apps/readest-app/src/__tests__/utils/opds-req.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deserializeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders'; // Mock environment for web platform vi.mock('@/services/environment', () => ({ @@ -74,6 +75,20 @@ describe('opdsReq', () => { expect(proxied).toContain('auth=Basic+dXNlcjpwYXNz'); }); + it('should include serialized custom headers in the proxy URL', () => { + const imageUrl = 'http://my-opds-server.local/covers/book.jpg'; + const proxied = getProxiedURL(imageUrl, '', true, { + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'secret', + }); + const params = new URL(proxied, 'https://web.readest.com').searchParams; + + expect(deserializeOPDSCustomHeaders(params.get('headers'))).toEqual({ + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'secret', + }); + }); + it('should strip credentials from URL before proxying', () => { const imageUrl = 'http://user:pass@my-opds-server.local/covers/book.jpg'; const proxied = getProxiedURL(imageUrl); diff --git a/apps/readest-app/src/app/api/opds/proxy/route.ts b/apps/readest-app/src/app/api/opds/proxy/route.ts index ce7d7386..dd4c9934 100644 --- a/apps/readest-app/src/app/api/opds/proxy/route.ts +++ b/apps/readest-app/src/app/api/opds/proxy/route.ts @@ -1,21 +1,27 @@ import { READEST_OPDS_USER_AGENT } from '@/services/constants'; import { NextRequest, NextResponse } from 'next/server'; +import { deserializeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders'; async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') { // Cloudflare Workers incorrectly decodes %26 to & in the url parameter value, // causing query parameters within the proxied URL (like &start_index=26) to be // treated as separate top-level parameters instead of part of the url value. // We work around this by manually extracting the url parameter - capturing everything - // from 'url=' until we hit our known parameters (&stream= or &auth=), then decoding it. + // from 'url=' until we hit our known parameters (&stream=, &auth=, or &headers=), then decoding it. const fullUrl = request.url; const urlParamStart = fullUrl.indexOf('url=') + 4; const streamParam = fullUrl.lastIndexOf('&stream='); const authParam = fullUrl.lastIndexOf('&auth='); - const urlParamEnd = Math.min(...[streamParam, authParam].filter((i) => i > 0), fullUrl.length); + const headersParam = fullUrl.lastIndexOf('&headers='); + const urlParamEnd = Math.min( + ...[streamParam, authParam, headersParam].filter((i) => i > 0), + fullUrl.length, + ); const encodedUrl = fullUrl.substring(urlParamStart, urlParamEnd); const url = decodeURIComponent(encodedUrl); const auth = request.nextUrl.searchParams.get('auth'); const stream = request.nextUrl.searchParams.get('stream'); + const customHeaders = deserializeOPDSCustomHeaders(request.nextUrl.searchParams.get('headers')); if (!url) { return NextResponse.json( @@ -35,13 +41,17 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 20000); - const headers: HeadersInit = { + const headers = new Headers({ 'User-Agent': READEST_OPDS_USER_AGENT, Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*', - }; + }); + + for (const [key, value] of Object.entries(customHeaders)) { + headers.set(key, value); + } if (auth) { - headers['Authorization'] = auth; + headers.set('Authorization', auth); } const response = await fetch(url, { diff --git a/apps/readest-app/src/app/opds/components/CatelogManager.tsx b/apps/readest-app/src/app/opds/components/CatelogManager.tsx index 3279bd0a..6718c6b3 100644 --- a/apps/readest-app/src/app/opds/components/CatelogManager.tsx +++ b/apps/readest-app/src/app/opds/components/CatelogManager.tsx @@ -12,6 +12,11 @@ import { saveSysSettings } from '@/helpers/settings'; import { OPDSCatalog } from '@/types/opds'; import { isLanAddress } from '@/utils/network'; import { validateOPDSURL } from '../utils/opdsUtils'; +import { + formatOPDSCustomHeadersInput, + hasOPDSCustomHeaders, + parseOPDSCustomHeadersInput, +} from '../utils/customHeaders'; import ModalPortal from '@/components/ModalPortal'; const POPULAR_CATALOGS: OPDSCatalog[] = [ @@ -49,11 +54,22 @@ async function validateOPDSCatalog( url: string, username?: string, password?: string, + customHeaders?: Record, ): Promise<{ valid: boolean; error?: string }> { - const result = await validateOPDSURL(url, username, password, isWebAppPlatform()); + const result = await validateOPDSURL(url, username, password, isWebAppPlatform(), customHeaders); return { valid: result.isValid, error: result.error }; } +const EMPTY_NEW_CATALOG = { + name: '', + url: '', + description: '', + username: '', + password: '', + customHeadersInput: '', + proxyConsent: false, +}; + export function CatalogManager() { const _ = useTranslation(); const router = useRouter(); @@ -61,17 +77,18 @@ export function CatalogManager() { const { settings } = useSettingsStore(); const [catalogs, setCatalogs] = useState(() => settings.opdsCatalogs || []); const [showAddDialog, setShowAddDialog] = useState(false); - const [newCatalog, setNewCatalog] = useState({ - name: '', - url: '', - description: '', - username: '', - password: '', - }); + const [newCatalog, setNewCatalog] = useState(EMPTY_NEW_CATALOG); const [showPassword, setShowPassword] = useState(false); const [urlError, setUrlError] = useState(''); + const [headerError, setHeaderError] = useState(''); + const [proxyConsentError, setProxyConsentError] = useState(''); const [isValidating, setIsValidating] = useState(false); const popularCatalogs = appService?.isOnlineCatalogsAccessible ? POPULAR_CATALOGS : []; + const hasSensitiveWebOPDSInput = + newCatalog.username.trim().length > 0 || + newCatalog.password.trim().length > 0 || + newCatalog.customHeadersInput.trim().length > 0; + const isWebCatalogProxyWarningRequired = isWebAppPlatform() && hasSensitiveWebOPDSInput; const saveCatalogs = (updatedCatalogs: OPDSCatalog[]) => { setCatalogs(updatedCatalogs); @@ -81,6 +98,12 @@ export function CatalogManager() { const handleAddCatalog = async () => { if (!newCatalog.name || !newCatalog.url) return; + const parsedHeaders = parseOPDSCustomHeadersInput(newCatalog.customHeadersInput); + if (parsedHeaders.error) { + setHeaderError(parsedHeaders.error); + return; + } + const urlLower = newCatalog.url.trim().toLowerCase(); if (!urlLower.startsWith('http://') && !urlLower.startsWith('https://')) { setUrlError(_('URL must start with http:// or https://')); @@ -96,13 +119,25 @@ export function CatalogManager() { return; } + if (isWebCatalogProxyWarningRequired && !newCatalog.proxyConsent) { + setProxyConsentError( + _( + 'Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.', + ), + ); + return; + } + setIsValidating(true); setUrlError(''); + setHeaderError(''); + setProxyConsentError(''); const validation = await validateOPDSCatalog( newCatalog.url, newCatalog.username || undefined, newCatalog.password || undefined, + parsedHeaders.headers, ); if (!validation.valid) { @@ -118,11 +153,16 @@ export function CatalogManager() { description: newCatalog.description, username: newCatalog.username || undefined, password: newCatalog.password || undefined, + customHeaders: hasOPDSCustomHeaders(parsedHeaders.headers) + ? parsedHeaders.headers + : undefined, }; saveCatalogs([catalog, ...catalogs]); - setNewCatalog({ name: '', url: '', description: '', username: '', password: '' }); + setNewCatalog(EMPTY_NEW_CATALOG); setUrlError(''); + setHeaderError(''); + setProxyConsentError(''); setIsValidating(false); setShowAddDialog(false); }; @@ -141,14 +181,16 @@ export function CatalogManager() { const handleOpenCatalog = (catalog: OPDSCatalog) => { const params = new URLSearchParams({ url: catalog.url }); - if (catalog.username) params.set('id', catalog.id); + params.set('id', catalog.id); router.push(`/opds?${params.toString()}`); }; const handleCloseDialog = () => { setShowAddDialog(false); - setNewCatalog({ name: '', url: '', description: '', username: '', password: '' }); + setNewCatalog(EMPTY_NEW_CATALOG); setUrlError(''); + setHeaderError(''); + setProxyConsentError(''); setShowPassword(false); }; @@ -216,6 +258,11 @@ export function CatalogManager() { {_('Username')}: {catalog.username}

)} + {hasOPDSCustomHeaders(catalog.customHeaders) && ( +

+ {_('Custom Headers')}: {Object.keys(catalog.customHeaders || {}).length} +

+ )}
@@ -317,7 +364,10 @@ export function CatalogManager() { setNewCatalog({ ...newCatalog, url: e.target.value })} + onChange={(e) => { + setNewCatalog({ ...newCatalog, url: e.target.value }); + setUrlError(''); + }} placeholder='https://example.com/opds' className='input input-bordered placeholder:text-sm' disabled={isValidating} @@ -337,7 +387,10 @@ export function CatalogManager() { setNewCatalog({ ...newCatalog, username: e.target.value })} + onChange={(e) => { + setNewCatalog({ ...newCatalog, username: e.target.value }); + setProxyConsentError(''); + }} placeholder={_('Username')} className='input input-bordered placeholder:text-sm' disabled={isValidating} @@ -353,7 +406,10 @@ export function CatalogManager() { setNewCatalog({ ...newCatalog, password: e.target.value })} + onChange={(e) => { + setNewCatalog({ ...newCatalog, password: e.target.value }); + setProxyConsentError(''); + }} placeholder={_('Password')} className='input input-bordered w-full pr-10 placeholder:text-sm' disabled={isValidating} @@ -374,6 +430,65 @@ export function CatalogManager() {
+
+
+ {_('Custom Headers (optional)')} +
+