883dae36aa
* fix(opds): allow LAN catalogs through the proxy in development The SSRF host blocklist added in #4638 unconditionally rejected private addresses, so the dev proxy returned 400 for LAN OPDS catalogs even though next dev runs on the developer's own machine where reaching the LAN is the normal use case. Skip the blocklist when NODE_ENV is development, matching the existing CatalogManager gate that only forbids LAN URLs in production. Production and test behavior are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(opds): negotiate Digest auth when the server rejects preemptive Basic with 400 Calibre's content server in digest mode, or auto mode over http, answers a Basic Authorization header with 400 Unsupported authentication method instead of a 401 challenge. The preemptive Basic header introduced in #4206 therefore dead-ended the request, since the auth retry only fired on 401 or 403, and digest catalogs failed with Failed to load OPDS feed: 400 Bad Request on every platform. When a request that carried preemptive Basic comes back 400, re-issue it once without credentials to surface the WWW-Authenticate challenge and let the existing negotiation pick the scheme the server actually wants. Verified end to end against a live Calibre server on web and Android. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(opds): skip SSL verification in auto-download like the manual download path The native download_file command validates TLS with rustls, which ignores the OS trust store, so downloads from self-signed or private-CA OPDS servers fail in the TLS handshake before any request reaches the server. The manual download path has passed skipSslVerification since #2900 as the workaround for #2871, but the auto-download path never did, so subscribed shelves failed to sync while feed browsing and manual downloads of the same books worked. Pass the same flag in downloadAndImport. Closes #4988 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { deserializeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders';
|
|
import { isBlockedHost } from '@/utils/network';
|
|
|
|
// Cap redirect hops so the SSRF host check below can re-run on every one.
|
|
const MAX_REDIRECTS = 5;
|
|
|
|
// In `next dev` the server runs on the developer's own machine, where a LAN
|
|
// OPDS catalog is the normal use case (the CatalogManager UI only forbids
|
|
// adding LAN URLs in production builds), so the host blocklist is skipped.
|
|
const isPrivateHostAllowed = () => process.env.NODE_ENV === 'development';
|
|
|
|
/**
|
|
* Fetch the target while running the SSRF host check on every redirect hop.
|
|
* `fetch`'s default `redirect: 'follow'` would let a public URL 302 to an
|
|
* internal address (e.g. the cloud metadata endpoint) after our initial host
|
|
* check passed, so we follow redirects manually and re-validate each hop.
|
|
* Throws `SsrfBlockedError` when a hop targets a blocked host or scheme.
|
|
*/
|
|
class SsrfBlockedError extends Error {}
|
|
|
|
async function fetchFollowingRedirects(
|
|
startUrl: string,
|
|
init: { method: 'GET' | 'HEAD'; headers: Headers; signal: AbortSignal },
|
|
): Promise<Response> {
|
|
let currentUrl = startUrl;
|
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
const parsed = new URL(currentUrl);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new SsrfBlockedError('Only http(s) URLs are supported');
|
|
}
|
|
if (!isPrivateHostAllowed() && isBlockedHost(parsed.hostname)) {
|
|
throw new SsrfBlockedError('This URL is not allowed');
|
|
}
|
|
const response = await fetch(currentUrl, { ...init, redirect: 'manual' });
|
|
if (response.status >= 300 && response.status < 400 && response.headers.has('location')) {
|
|
currentUrl = new URL(response.headers.get('location')!, currentUrl).toString();
|
|
continue;
|
|
}
|
|
return response;
|
|
}
|
|
throw new SsrfBlockedError('Too many redirects');
|
|
}
|
|
|
|
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=, &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 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(
|
|
{ error: 'Missing URL parameter. Usage: /api/opds/proxy?url=YOUR_OPDS_URL' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(url);
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
|
}
|
|
|
|
// SSRF guard: this proxy fetches an arbitrary client-supplied URL server-side,
|
|
// so reject non-http(s) schemes and internal/loopback/link-local targets
|
|
// before making any request (the redirect loop re-checks every hop).
|
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
return NextResponse.json({ error: 'Only http(s) URLs are supported' }, { status: 400 });
|
|
}
|
|
if (!isPrivateHostAllowed() && isBlockedHost(parsedUrl.hostname)) {
|
|
return NextResponse.json({ error: 'This URL is not allowed' }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
console.log(`[OPDS Proxy] ${method}: ${url}`);
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
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.set('Authorization', auth);
|
|
}
|
|
|
|
const response = await fetchFollowingRedirects(url, {
|
|
method,
|
|
headers,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!response.ok) {
|
|
console.error(`[OPDS Proxy] HTTP ${response.status} for ${url}`);
|
|
if (method === 'HEAD') {
|
|
if (response.status === 401) {
|
|
return new NextResponse(null, {
|
|
status: 403,
|
|
headers: {
|
|
...Object.fromEntries(response.headers.entries()),
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.text();
|
|
if (response.status === 401) {
|
|
return new NextResponse(data, {
|
|
status: 403,
|
|
headers: {
|
|
...Object.fromEntries(response.headers.entries()),
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|
|
return new NextResponse(data, {
|
|
status: response.status,
|
|
headers: {
|
|
...Object.fromEntries(response.headers.entries()),
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|
|
|
|
const contentType = response.headers.get('Content-Type') || 'text/xml';
|
|
const contentLength = response.headers.get('Content-Length');
|
|
const contentEncoding = response.headers.get('Content-Encoding');
|
|
const transferEncoding = response.headers.get('Transfer-Encoding');
|
|
const upstreamContentDisposition = response.headers.get('Content-Disposition');
|
|
console.log(
|
|
`[OPDS Proxy] upstream OK: ${url} ` +
|
|
`content-type=${contentType} content-length=${contentLength ?? '(none)'} ` +
|
|
`content-encoding=${contentEncoding ?? '(none)'} ` +
|
|
`transfer-encoding=${transferEncoding ?? '(none)'} ` +
|
|
`content-disposition=${upstreamContentDisposition ?? '(none)'}`,
|
|
);
|
|
|
|
// Headers that must NOT be forwarded as-is when we proxy the body:
|
|
// - content-encoding: fetch() has already decoded gzip/br/deflate, so
|
|
// the body the client receives is plain. Forwarding the original
|
|
// Content-Encoding makes the browser try to decode it again, which
|
|
// truncates or empties the response. This was the cause of
|
|
// Content-Length: 0 / 0-byte downloads from Calibre and similar.
|
|
// - content-length: must match the bytes we actually emit (post-decode).
|
|
// We set the right value below where we know it.
|
|
// - transfer-encoding / connection / keep-alive: hop-by-hop headers,
|
|
// must not cross the proxy boundary.
|
|
const excludedHeaders = new Set([
|
|
'content-encoding',
|
|
'content-length',
|
|
'transfer-encoding',
|
|
'connection',
|
|
'keep-alive',
|
|
]);
|
|
|
|
// Use a Headers object so name comparison is case-insensitive — this
|
|
// prevents the `content-type` (lowercase from Headers.entries) and
|
|
// `Content-Type` (title-case override) duplication that produced
|
|
// "application/epub+zip, application/epub+zip" responses.
|
|
const buildResponseHeaders = (extras: Record<string, string>) => {
|
|
const h = new Headers();
|
|
for (const [key, value] of response.headers.entries()) {
|
|
if (!excludedHeaders.has(key.toLowerCase())) {
|
|
h.set(key, value);
|
|
}
|
|
}
|
|
// Don't cache file downloads — a single broken response would otherwise
|
|
// be cached for 5 minutes and keep returning 0 bytes. Catalog feeds
|
|
// (XML/JSON) are still cacheable.
|
|
const isFileDownload =
|
|
stream === 'true' ||
|
|
(upstreamContentDisposition ?? '').toLowerCase().includes('attachment');
|
|
h.set('Cache-Control', isFileDownload ? 'no-store' : 'public, max-age=300');
|
|
h.set('Access-Control-Allow-Origin', '*');
|
|
h.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
h.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
for (const [key, value] of Object.entries(extras)) {
|
|
h.set(key, value);
|
|
}
|
|
return h;
|
|
};
|
|
|
|
if (method === 'HEAD') {
|
|
console.log(`[OPDS Proxy] HEAD Success: ${url}`);
|
|
return new NextResponse(null, {
|
|
status: 200,
|
|
headers: buildResponseHeaders({
|
|
'Content-Type': contentType,
|
|
...(contentLength ? { 'Content-Length': contentLength } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (stream === 'true' && contentLength && parseInt(contentLength) > 1024 * 1024) {
|
|
console.log(`[OPDS Proxy] Streaming: ${url} (${contentLength} bytes)`);
|
|
const headers = buildResponseHeaders({
|
|
'Content-Type': contentType,
|
|
// Surface the upstream length to the client without setting the real
|
|
// Content-Length header (which must match the streamed bytes — and
|
|
// we let Next.js / the runtime compute that).
|
|
'X-Content-Length': contentLength,
|
|
'Access-Control-Expose-Headers': 'X-Content-Length',
|
|
});
|
|
return new NextResponse(response.body, { status: 200, headers });
|
|
} else {
|
|
const buf = await response.arrayBuffer();
|
|
const length = buf.byteLength;
|
|
console.log(`[OPDS Proxy] Buffered Success: ${url} (${length} bytes)`);
|
|
return new NextResponse(buf, {
|
|
status: 200,
|
|
headers: buildResponseHeaders({
|
|
'Content-Type': contentType,
|
|
'Content-Length': length.toString(),
|
|
}),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('[OPDS Proxy] Error:', error);
|
|
|
|
if (error instanceof SsrfBlockedError) {
|
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
if (error.name === 'AbortError') {
|
|
return NextResponse.json(
|
|
{ error: 'Request timeout - the OPDS server took too long to respond' },
|
|
{ status: 504 },
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: error.message,
|
|
url: url,
|
|
hint: 'Check if the OPDS URL is accessible and returns valid OPDS/Atom/JSON content',
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ error: 'Failed to fetch OPDS feed', url: url }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
return handleRequest(request, 'GET');
|
|
}
|
|
|
|
export async function HEAD(request: NextRequest) {
|
|
return handleRequest(request, 'HEAD');
|
|
}
|
|
|
|
export async function OPTIONS(_: NextRequest) {
|
|
return new NextResponse(null, {
|
|
status: 200,
|
|
headers: {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
});
|
|
}
|