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)')}
+
+
+
+ {isWebCatalogProxyWarningRequired && (
+
+
+ {proxyConsentError && (
+
+ {proxyConsentError}
+
+ )}
+
+ )}
+
{_('Description (optional)')}
diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx
index 1419afa3..8dd9572a 100644
--- a/apps/readest-app/src/app/opds/page.tsx
+++ b/apps/readest-app/src/app/opds/page.tsx
@@ -42,6 +42,7 @@ import { FeedView } from './components/FeedView';
import { PublicationView } from './components/PublicationView';
import { SearchView } from './components/SearchView';
import { Navigation } from './components/Navigation';
+import { normalizeOPDSCustomHeaders } from './utils/customHeaders';
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
@@ -88,6 +89,7 @@ export default function BrowserPage() {
const catalogId = searchParams?.get('id') || '';
const usernameRef = useRef(undefined);
const passwordRef = useRef(undefined);
+ const customHeadersRef = useRef>({});
const startURLRef = useRef(undefined);
const loadingOPDSRef = useRef(false);
const historyIndexRef = useRef(-1);
@@ -172,7 +174,8 @@ export default function BrowserPage() {
const useProxy = isWebAppPlatform();
const username = usernameRef.current || '';
const password = passwordRef.current || '';
- const res = await fetchWithAuth(url, username, password, useProxy);
+ const customHeaders = customHeadersRef.current;
+ const res = await fetchWithAuth(url, username, password, useProxy, {}, customHeaders);
if (!res.ok) {
if (isSearch && res.status === 404) {
@@ -322,6 +325,7 @@ export default function BrowserPage() {
usernameRef.current = null;
passwordRef.current = null;
}
+ customHeadersRef.current = normalizeOPDSCustomHeaders(catalog?.customHeaders);
if (libraryLoaded) {
loadOPDS(url);
}
@@ -423,17 +427,21 @@ export default function BrowserPage() {
} else {
const username = usernameRef.current || '';
const password = passwordRef.current || '';
+ const customHeaders = customHeadersRef.current;
const useProxy = needsProxy(url);
- let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
+ let downloadUrl = useProxy ? getProxiedURL(url, '', true, customHeaders) : url;
const headers: Record = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: '*/*',
+ ...(!useProxy ? customHeaders : {}),
};
if (username || password) {
- const authHeader = await probeAuth(url, username, password, useProxy);
+ const authHeader = await probeAuth(url, username, password, useProxy, customHeaders);
if (authHeader) {
- headers['Authorization'] = authHeader;
- downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
+ if (!useProxy) {
+ headers['Authorization'] = authHeader;
+ }
+ downloadUrl = useProxy ? getProxiedURL(url, authHeader, true, customHeaders) : url;
}
}
@@ -493,7 +501,8 @@ export default function BrowserPage() {
if (!appService) return url;
const username = usernameRef.current || '';
const password = passwordRef.current || '';
- if (!username && !password) {
+ const customHeaders = customHeadersRef.current;
+ if (!username && !password && Object.keys(customHeaders).length === 0) {
return needsProxy(url) ? getProxiedURL(url, '', true) : url;
}
@@ -504,13 +513,17 @@ export default function BrowserPage() {
return await appService.getImageURL(cachedPath);
} else {
const useProxy = needsProxy(url);
- let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
- const headers: Record = {};
+ let downloadUrl = useProxy ? getProxiedURL(url, '', true, customHeaders) : url;
+ const headers: Record = {
+ ...(!useProxy ? customHeaders : {}),
+ };
if (username || password) {
- const authHeader = await probeAuth(url, username, password, useProxy);
+ const authHeader = await probeAuth(url, username, password, useProxy, customHeaders);
if (authHeader) {
- headers['Authorization'] = authHeader;
- downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
+ if (!useProxy) {
+ headers['Authorization'] = authHeader;
+ }
+ downloadUrl = useProxy ? getProxiedURL(url, authHeader, true, customHeaders) : url;
}
}
await downloadFile({
diff --git a/apps/readest-app/src/app/opds/utils/customHeaders.ts b/apps/readest-app/src/app/opds/utils/customHeaders.ts
new file mode 100644
index 00000000..77ada6ae
--- /dev/null
+++ b/apps/readest-app/src/app/opds/utils/customHeaders.ts
@@ -0,0 +1,80 @@
+export type OPDSCustomHeaders = Record;
+
+export const normalizeOPDSCustomHeaders = (
+ headers?: OPDSCustomHeaders | null,
+): OPDSCustomHeaders => {
+ return Object.fromEntries(
+ Object.entries(headers ?? {})
+ .map(([key, value]) => [key.trim(), String(value).trim()] as const)
+ .filter(([key, value]) => key.length > 0 && value.length > 0),
+ );
+};
+
+export const hasOPDSCustomHeaders = (headers?: OPDSCustomHeaders | null): boolean => {
+ return Object.keys(normalizeOPDSCustomHeaders(headers)).length > 0;
+};
+
+export const parseOPDSCustomHeadersInput = (
+ input: string,
+): { headers: OPDSCustomHeaders; error?: string } => {
+ const headers: OPDSCustomHeaders = {};
+
+ for (const [index, rawLine] of input.split('\n').entries()) {
+ const line = rawLine.trim();
+ if (!line) continue;
+
+ const separatorIndex = line.indexOf(':');
+ if (separatorIndex <= 0) {
+ return {
+ headers: {},
+ error: `Custom header line ${index + 1} must use the format "Header-Name: value".`,
+ };
+ }
+
+ const key = line.slice(0, separatorIndex).trim();
+ const value = line.slice(separatorIndex + 1).trim();
+
+ if (!key || !value) {
+ return {
+ headers: {},
+ error: `Custom header line ${index + 1} must include both a name and a value.`,
+ };
+ }
+
+ headers[key] = value;
+ }
+
+ return { headers };
+};
+
+export const formatOPDSCustomHeadersInput = (headers?: OPDSCustomHeaders | null): string => {
+ return Object.entries(normalizeOPDSCustomHeaders(headers))
+ .map(([key, value]) => `${key}: ${value}`)
+ .join('\n');
+};
+
+export const serializeOPDSCustomHeaders = (headers?: OPDSCustomHeaders | null): string | null => {
+ const normalizedHeaders = normalizeOPDSCustomHeaders(headers);
+ if (Object.keys(normalizedHeaders).length === 0) {
+ return null;
+ }
+
+ return JSON.stringify(normalizedHeaders);
+};
+
+export const deserializeOPDSCustomHeaders = (value?: string | null): OPDSCustomHeaders => {
+ if (!value) {
+ return {};
+ }
+
+ try {
+ const parsed = JSON.parse(value);
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
+ return {};
+ }
+
+ return normalizeOPDSCustomHeaders(parsed as OPDSCustomHeaders);
+ } catch {
+ return {};
+ }
+};
diff --git a/apps/readest-app/src/app/opds/utils/opdsReq.ts b/apps/readest-app/src/app/opds/utils/opdsReq.ts
index a70a67bd..6870e9c9 100644
--- a/apps/readest-app/src/app/opds/utils/opdsReq.ts
+++ b/apps/readest-app/src/app/opds/utils/opdsReq.ts
@@ -7,6 +7,11 @@ import {
} from '@/services/environment';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
+import {
+ OPDSCustomHeaders,
+ normalizeOPDSCustomHeaders,
+ serializeOPDSCustomHeaders,
+} from './customHeaders';
const OPDS_PROXY_URL = `${getAPIBaseUrl()}/opds/proxy`;
const NODE_OPDS_PROXY_URL = `${getNodeAPIBaseUrl()}/opds/proxy`;
@@ -57,7 +62,12 @@ const getProxyBaseUrl = (url: string): string => {
/**
* Generate proxied URL for OPDS requests
*/
-export const getProxiedURL = (url: string, auth: string = '', stream = false): string => {
+export const getProxiedURL = (
+ url: string,
+ auth: string = '',
+ stream = false,
+ customHeaders: OPDSCustomHeaders = {},
+): string => {
if (url.startsWith('http')) {
const { url: cleanUrl } = extractCredentialsFromURL(url);
const params = new URLSearchParams();
@@ -66,6 +76,10 @@ export const getProxiedURL = (url: string, auth: string = '', stream = false): s
if (auth) {
params.append('auth', auth);
}
+ const serializedHeaders = serializeOPDSCustomHeaders(customHeaders);
+ if (serializedHeaders) {
+ params.append('headers', serializedHeaders);
+ }
const baseUrl = getProxyBaseUrl(url);
const proxyUrl = `${baseUrl}?${params.toString()}`;
return proxyUrl;
@@ -191,6 +205,7 @@ export const probeAuth = async (
username?: string,
password?: string,
useProxy = false,
+ customHeaders: OPDSCustomHeaders = {},
): Promise => {
const {
url: cleanUrl,
@@ -200,16 +215,20 @@ export const probeAuth = async (
const finalUsername = username || urlUsername;
const finalPassword = password || urlPassword;
+ const normalizedCustomHeaders = normalizeOPDSCustomHeaders(customHeaders);
// No credentials provided, can't generate auth header
if (!finalUsername || !finalPassword) {
return null;
}
- const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
+ const fetchURL = useProxy
+ ? getProxiedURL(cleanUrl, '', false, normalizedCustomHeaders)
+ : cleanUrl;
const headers: Record = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml, */*',
+ ...(!useProxy ? normalizedCustomHeaders : {}),
};
// Probe with HEAD request
@@ -275,6 +294,7 @@ export const fetchWithAuth = async (
password?: string,
useProxy = false,
options: RequestInit = {},
+ customHeaders: OPDSCustomHeaders = {},
): Promise => {
const {
url: cleanUrl,
@@ -284,11 +304,15 @@ export const fetchWithAuth = async (
const finalUsername = username || urlUsername;
const finalPassword = password || urlPassword;
+ const normalizedCustomHeaders = normalizeOPDSCustomHeaders(customHeaders);
- const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
+ const fetchURL = useProxy
+ ? getProxiedURL(cleanUrl, '', false, normalizedCustomHeaders)
+ : cleanUrl;
const headers: Record = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml, */*',
+ ...(!useProxy ? normalizedCustomHeaders : {}),
...(options.headers as Record),
};
@@ -320,7 +344,9 @@ export const fetchWithAuth = async (
}
if (authHeader) {
- const finalUrl = useProxy ? `${fetchURL}&auth=${encodeURIComponent(authHeader)}` : fetchURL;
+ const finalUrl = useProxy
+ ? getProxiedURL(cleanUrl, authHeader, false, normalizedCustomHeaders)
+ : fetchURL;
res = await fetch(finalUrl, {
...options,
method: options.method || 'GET',
diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts
index 9508de4a..5b61fe72 100644
--- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts
+++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts
@@ -97,13 +97,21 @@ export const validateOPDSURL = async (
username?: string,
password?: string,
useProxy = false,
+ customHeaders: Record = {},
): Promise => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
- const res = await fetchWithAuth(url, username, password, useProxy, {
- signal: controller.signal,
- });
+ const res = await fetchWithAuth(
+ url,
+ username,
+ password,
+ useProxy,
+ {
+ signal: controller.signal,
+ },
+ customHeaders,
+ );
clearTimeout(timeout);
if (!res.ok) {
diff --git a/apps/readest-app/src/types/opds.ts b/apps/readest-app/src/types/opds.ts
index ca0eb9b5..e2c2426a 100644
--- a/apps/readest-app/src/types/opds.ts
+++ b/apps/readest-app/src/types/opds.ts
@@ -23,6 +23,7 @@ export interface OPDSCatalog {
icon?: string;
username?: string;
password?: string;
+ customHeaders?: Record;
}
export interface OPDSFeed {