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 <git@albertwang.dev>
This commit is contained in:
Yi-Shun Wang
2026-04-04 06:01:16 -04:00
committed by GitHub
parent 52ac74bbad
commit 45bd355981
10 changed files with 364 additions and 40 deletions
@@ -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 = `<feed xmlns="http://www.w3.org/2005/Atom"><title>Auth Feed</title></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,
);
});
});
@@ -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');
});
});
@@ -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);
@@ -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, {
@@ -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<string, string>,
): 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<OPDSCatalog[]>(() => 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}
</p>
)}
{hasOPDSCustomHeaders(catalog.customHeaders) && (
<p className='text-base-content/50 mt-1 text-xs'>
{_('Custom Headers')}: {Object.keys(catalog.customHeaders || {}).length}
</p>
)}
</div>
</div>
<div className='card-actions mt-4 justify-end'>
@@ -317,7 +364,10 @@ export function CatalogManager() {
<input
type='url'
value={newCatalog.url}
onChange={(e) => 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() {
<input
type='text'
value={newCatalog.username}
onChange={(e) => 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() {
<input
type={showPassword ? 'text' : 'password'}
value={newCatalog.password}
onChange={(e) => 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() {
</div>
</div>
<div className='form-control'>
<div className='label'>
<span className='label-text'>{_('Custom Headers (optional)')}</span>
</div>
<textarea
value={newCatalog.customHeadersInput}
onChange={(e) => {
setNewCatalog({ ...newCatalog, customHeadersInput: e.target.value });
setHeaderError('');
setProxyConsentError('');
}}
placeholder={formatOPDSCustomHeadersInput({
'CF-Access-Client-Id': 'your-client-id',
'CF-Access-Client-Secret': 'your-client-secret',
})}
className='textarea textarea-bordered font-mono text-sm placeholder:text-xs'
rows={4}
disabled={isValidating}
spellCheck={false}
/>
<div className='label'>
<span className='label-text-alt text-base-content/60'>
{_('Add one header per line using "Header-Name: value".')}
</span>
</div>
{headerError && (
<div className='label pt-0'>
<span className='label-text-alt text-error'>{headerError}</span>
</div>
)}
</div>
{isWebCatalogProxyWarningRequired && (
<div className='form-control border-warning/30 bg-warning/10 rounded-lg border p-4'>
<label className='label cursor-pointer items-start justify-start gap-3 p-0'>
<input
type='checkbox'
className='checkbox checkbox-sm mt-0.5'
checked={newCatalog.proxyConsent}
onChange={(e) => {
setNewCatalog({ ...newCatalog, proxyConsent: e.target.checked });
setProxyConsentError('');
}}
disabled={isValidating}
/>
<span className='label-text text-sm leading-6'>
{_(
'I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.',
)}
</span>
</label>
{proxyConsentError && (
<div className='label px-0 pb-0 pt-2'>
<span className='label-text-alt text-error'>{proxyConsentError}</span>
</div>
)}
</div>
)}
<div className='form-control'>
<div className='label'>
<span className='label-text'>{_('Description (optional)')}</span>
+24 -11
View File
@@ -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<string | null | undefined>(undefined);
const passwordRef = useRef<string | null | undefined>(undefined);
const customHeadersRef = useRef<Record<string, string>>({});
const startURLRef = useRef<string | null | undefined>(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<string, string> = {
'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<string, string> = {};
let downloadUrl = useProxy ? getProxiedURL(url, '', true, customHeaders) : url;
const headers: Record<string, string> = {
...(!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({
@@ -0,0 +1,80 @@
export type OPDSCustomHeaders = Record<string, string>;
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 {};
}
};
+30 -4
View File
@@ -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<string | null> => {
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<string, string> = {
'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<Response> => {
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<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml, */*',
...(!useProxy ? normalizedCustomHeaders : {}),
...(options.headers as Record<string, string>),
};
@@ -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',
@@ -97,13 +97,21 @@ export const validateOPDSURL = async (
username?: string,
password?: string,
useProxy = false,
customHeaders: Record<string, string> = {},
): Promise<ValidationResult> => {
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) {
+1
View File
@@ -23,6 +23,7 @@ export interface OPDSCatalog {
icon?: string;
username?: string;
password?: string;
customHeaders?: Record<string, string>;
}
export interface OPDSFeed {