From 2d7d6b08a9470f7bf7e04410113966c305c67360 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 16 Jan 2026 17:13:30 +0100 Subject: [PATCH] compat(opds): parse attachment filename from download requests, closes #2969 (#2976) --- .../src-tauri/src/transfer_file.rs | 22 +++++++-- .../src/app/api/opds/proxy/route.ts | 2 + apps/readest-app/src/app/opds/page.tsx | 19 +++++--- .../readest-app/src/app/opds/utils/opdsReq.ts | 45 +++++-------------- apps/readest-app/src/libs/storage.ts | 11 +++-- apps/readest-app/src/utils/transfer.ts | 8 ++-- 6 files changed, 59 insertions(+), 48 deletions(-) diff --git a/apps/readest-app/src-tauri/src/transfer_file.rs b/apps/readest-app/src-tauri/src/transfer_file.rs index 6a611b03..ffbf1ea4 100644 --- a/apps/readest-app/src-tauri/src/transfer_file.rs +++ b/apps/readest-app/src-tauri/src/transfer_file.rs @@ -110,7 +110,7 @@ pub async fn download_file( single_threaded: Option, skip_ssl_verification: Option, on_progress: Channel, -) -> Result<()> { +) -> Result> { use futures::stream::{self, StreamExt}; use std::cmp::min; use tokio::io::AsyncSeekExt; @@ -130,7 +130,7 @@ pub async fn download_file( headers: &HashMap, body: &Option, on_progress: Channel, - ) -> Result<()> { + ) -> Result> { let mut request = if let Some(body) = body { client.post(url).body(body.clone()) } else { @@ -149,6 +149,13 @@ pub async fn download_file( )); } + let mut resp_headers = HashMap::new(); + for (key, value) in response.headers().iter() { + if let Ok(val_str) = value.to_str() { + resp_headers.insert(key.to_string(), val_str.to_string()); + } + } + let total = response.content_length().unwrap_or(0); let mut file = BufWriter::new(File::create(file_path).await?); let mut stream = response.bytes_stream(); @@ -165,7 +172,7 @@ pub async fn download_file( } file.flush().await?; - Ok(()) + Ok(resp_headers) } if force_single { @@ -193,6 +200,13 @@ pub async fn download_file( .and_then(|s| s.parse::().ok()) .unwrap_or(0); + let mut resp_headers = HashMap::new(); + for (key, value) in range_resp.headers().iter() { + if let Ok(val_str) = value.to_str() { + resp_headers.insert(key.to_string(), val_str.to_string()); + } + } + if !accept_ranges || total == 0 { return single_threaded_download(&client, url, file_path, &headers, &body, on_progress) .await; @@ -260,7 +274,7 @@ pub async fn download_file( }) .await; - Ok(()) + Ok(resp_headers) } #[command] 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 c8836857..efeaabf7 100644 --- a/apps/readest-app/src/app/api/opds/proxy/route.ts +++ b/apps/readest-app/src/app/api/opds/proxy/route.ts @@ -120,6 +120,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') { return new NextResponse(response.body, { status: 200, headers: { + ...Object.fromEntries(response.headers.entries()), 'Content-Type': contentType, 'X-Content-Length': contentLength || '', 'Cache-Control': 'public, max-age=300', @@ -137,6 +138,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') { return new NextResponse(buf, { status: 200, headers: { + ...Object.fromEntries(response.headers.entries()), 'Content-Type': contentType, 'Content-Length': length.toString(), 'Cache-Control': 'public, max-age=300', diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx index 7b9f5b18..57c29a87 100644 --- a/apps/readest-app/src/app/opds/page.tsx +++ b/apps/readest-app/src/app/opds/page.tsx @@ -436,14 +436,14 @@ export default function BrowserPage() { } } - const probedFilename = await probeFilename(url, useProxy, headers); const pathname = decodeURIComponent(new URL(url).pathname); const ext = getFileExtFromMimeType(parsed?.mediaType) || getFileExtFromPath(pathname); const basename = pathname.replaceAll('/', '_'); - const filename = probedFilename ? probedFilename : ext ? `${basename}.${ext}` : basename; - const dstFilePath = await appService?.resolveFilePath(filename, 'Cache'); - console.log('Downloading to:', dstFilePath); - await downloadFile({ + const filename = ext ? `${basename}.${ext}` : basename; + let dstFilePath = await appService?.resolveFilePath(filename, 'Cache'); + console.log('Downloading to:', url, dstFilePath); + + const responseHeaders = await downloadFile({ appService, dst: dstFilePath, cfp: '', @@ -453,6 +453,15 @@ export default function BrowserPage() { skipSslVerification: true, onProgress, }); + const probedFilename = await probeFilename(responseHeaders); + if (probedFilename) { + const newFilePath = await appService?.resolveFilePath(probedFilename, 'Cache'); + await appService?.copyFile(dstFilePath, newFilePath, 'None'); + await appService?.deleteFile(dstFilePath, 'None'); + console.log('Renamed downloaded file to:', newFilePath); + dstFilePath = newFilePath; + } + const { library, setLibrary } = useLibraryStore.getState(); const book = await appService.importBook(dstFilePath, library); if (user && book && !book.uploadedAt && settings.autoUpload) { diff --git a/apps/readest-app/src/app/opds/utils/opdsReq.ts b/apps/readest-app/src/app/opds/utils/opdsReq.ts index 738a7020..30619db1 100644 --- a/apps/readest-app/src/app/opds/utils/opdsReq.ts +++ b/apps/readest-app/src/app/opds/utils/opdsReq.ts @@ -245,40 +245,19 @@ export const probeAuth = async ( return createBasicAuth(finalUsername, finalPassword); }; -export const probeFilename = async ( - url: string, - useProxy = false, - headers?: Record, -) => { - const { url: cleanUrl } = extractCredentialsFromURL(url); +export const probeFilename = async (headers: Record) => { + const contentDisposition = headers['content-disposition']; + if (contentDisposition) { + const extendedMatch = contentDisposition.match( + /filename\*\s*=\s*(?:utf-8|UTF-8)'[^']*'([^;\s]+)/i, + ); + if (extendedMatch?.[1]) { + return decodeURIComponent(extendedMatch[1]); + } - const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl; - const baseHeaders: Record = { - 'User-Agent': READEST_OPDS_USER_AGENT, - Accept: 'application/atom+xml, application/xml, text/xml, */*', - }; - - const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch; - const res = await fetch(fetchURL, { - method: 'HEAD', - headers: { ...baseHeaders, ...(headers || {}) }, - danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true }, - }); - - if (res.ok) { - const contentDisposition = res.headers.get('content-disposition'); - if (contentDisposition) { - const extendedMatch = contentDisposition.match( - /filename\*\s*=\s*(?:utf-8|UTF-8)'[^']*'([^;\s]+)/i, - ); - if (extendedMatch?.[1]) { - return decodeURIComponent(extendedMatch[1]); - } - - const plainMatch = contentDisposition.match(/filename\s*=\s*["']?([^"';\s]+)["']?/i); - if (plainMatch?.[1]) { - return decodeURIComponent(plainMatch[1]); - } + const plainMatch = contentDisposition.match(/filename\s*=\s*["']?([^"';\s]+)["']?/i); + if (plainMatch?.[1]) { + return decodeURIComponent(plainMatch[1]); } } diff --git a/apps/readest-app/src/libs/storage.ts b/apps/readest-app/src/libs/storage.ts index e5de573e..5a43ad69 100644 --- a/apps/readest-app/src/libs/storage.ts +++ b/apps/readest-app/src/libs/storage.ts @@ -149,10 +149,15 @@ export const downloadFile = async ({ } if (isWebAppPlatform()) { - const file = await webDownload(downloadUrl, onProgress, headers); - await appService.writeFile(dst, 'None', await file.arrayBuffer()); + const { headers: responseHeaders, blob } = await webDownload( + downloadUrl, + onProgress, + headers, + ); + await appService.writeFile(dst, 'None', await blob.arrayBuffer()); + return responseHeaders; } else { - await tauriDownload( + return await tauriDownload( downloadUrl, dst, onProgress, diff --git a/apps/readest-app/src/utils/transfer.ts b/apps/readest-app/src/utils/transfer.ts index bab648f7..a1df29d1 100644 --- a/apps/readest-app/src/utils/transfer.ts +++ b/apps/readest-app/src/utils/transfer.ts @@ -61,6 +61,7 @@ export const webDownload = async ( throw new Error(UploadFileError.DownloadFailed); } + const responseHeaders = Object.fromEntries(response.headers.entries()); const contentLength = response.headers.get('Content-Length') || response.headers.get('X-Content-Length'); if (!contentLength) throw new Error('Cannot track progress: Content-Length missing'); @@ -87,7 +88,7 @@ export const webDownload = async ( } } - return new Blob(chunks as BlobPart[]); + return { headers: responseHeaders, blob: new Blob(chunks as BlobPart[]) }; }; export const tauriUpload = async ( @@ -124,7 +125,7 @@ export const tauriDownload = async ( body?: string, singleThreaded?: boolean, skipSslVerification?: boolean, -): Promise => { +): Promise> => { const ids = new Uint32Array(1); window.crypto.getRandomValues(ids); const id = ids[0]; @@ -134,7 +135,7 @@ export const tauriDownload = async ( onProgress.onmessage = progressHandler; } - await invoke('download_file', { + const responseHeaders = await invoke>('download_file', { id, url, filePath, @@ -144,4 +145,5 @@ export const tauriDownload = async ( singleThreaded, skipSslVerification, }); + return responseHeaders; };