forked from akai/readest
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:
@@ -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>
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface OPDSCatalog {
|
||||
icon?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface OPDSFeed {
|
||||
|
||||
Reference in New Issue
Block a user