compat(opds): parse attachment filename from download requests, closes #2969 (#2976)

This commit is contained in:
Huang Xin
2026-01-16 17:13:30 +01:00
committed by GitHub
parent b1419f9c01
commit 2d7d6b08a9
6 changed files with 59 additions and 48 deletions
@@ -110,7 +110,7 @@ pub async fn download_file(
single_threaded: Option<bool>,
skip_ssl_verification: Option<bool>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
) -> Result<HashMap<String, String>> {
use futures::stream::{self, StreamExt};
use std::cmp::min;
use tokio::io::AsyncSeekExt;
@@ -130,7 +130,7 @@ pub async fn download_file(
headers: &HashMap<String, String>,
body: &Option<String>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
) -> Result<HashMap<String, String>> {
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::<u64>().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]
@@ -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',
+14 -5
View File
@@ -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) {
+12 -33
View File
@@ -245,40 +245,19 @@ export const probeAuth = async (
return createBasicAuth(finalUsername, finalPassword);
};
export const probeFilename = async (
url: string,
useProxy = false,
headers?: Record<string, string>,
) => {
const { url: cleanUrl } = extractCredentialsFromURL(url);
export const probeFilename = async (headers: Record<string, string>) => {
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<string, string> = {
'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]);
}
}
+8 -3
View File
@@ -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,
+5 -3
View File
@@ -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<void> => {
): Promise<Record<string, string>> => {
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<Record<string, string>>('download_file', {
id,
url,
filePath,
@@ -144,4 +145,5 @@ export const tauriDownload = async (
singleThreaded,
skipSslVerification,
});
return responseHeaders;
};