use crate::{
UrlInfo,
http::{
ContentDisposition, GetRequestError, GetResponse, HttpClient, HttpError, HttpHeaders,
HttpRequestBuilder, HttpResponse,
},
url_info::FileId,
};
use fast_pull::PullResult;
use std::{borrow::Borrow, future::Future};
use url::Url;
pub type PrefetchResult<Client> =
PullResult<(UrlInfo, GetResponse<Client>), GetRequestError<Client>>;
pub trait Prefetch<Client: HttpClient> {
fn prefetch(&self, url: Url) -> impl Future<Output = PrefetchResult<Client>> + Send;
}
impl<Client, BorrowClient> Prefetch<Client> for BorrowClient
where
Client: HttpClient,
BorrowClient: Borrow<Client> + Sync,
{
async fn prefetch(&self, url: Url) -> PrefetchResult<Client> {
prefetch(self.borrow(), url).await
}
}
fn get_filename(headers: &impl HttpHeaders, url: &Url) -> String {
headers
.get("content-disposition")
.ok()
.and_then(|s| ContentDisposition::parse(s.as_ref()).filename)
.filter(|s| !s.trim().is_empty())
.or_else(|| {
url.path_segments()
.and_then(|mut segments| segments.next_back())
.map(|s| {
let s = urlencoding::decode_binary(s.as_bytes());
String::from_utf8_lossy(&s).into_owned()
})
.filter(|s| !s.trim().is_empty())
})
.or_else(|| url.host_str().map(|s| s.replace('.', "_")))
.unwrap_or_else(|| url.to_string().replace('.', "_"))
}
async fn prefetch<Client: HttpClient>(client: &Client, url: Url) -> PrefetchResult<Client> {
let (no_range_fut, range_fut) = (
prefetch_no_range(client, url.clone()),
is_support_range(client, url),
);
let (result_no_range, result_range) = tokio::join!(no_range_fut, range_fut);
let mut res = result_no_range?;
if matches!(result_range, Ok(true)) {
res.0.supports_range = true;
if res.0.size != 0 {
res.0.fast_download = true;
}
}
Ok(res)
}
async fn prefetch_no_range<Client: HttpClient>(
client: &Client,
url: Url,
) -> PrefetchResult<Client> {
let resp = client.get(url, None).send().await?;
let headers = resp.headers();
let size = headers
.get("content-length")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let final_url = resp.url();
Ok((
UrlInfo {
final_url: final_url.clone(),
raw_name: get_filename(headers, final_url),
size,
supports_range: false,
fast_download: false,
file_id: FileId::new(
headers.get("etag").ok().as_deref(),
headers.get("last-modified").ok().as_deref(),
),
content_type: headers.get("content-type").ok().map(String::from),
},
resp,
))
}
async fn is_support_range<Client: HttpClient>(
client: &Client,
url: Url,
) -> PullResult<bool, HttpError<Client>> {
let resp = client
.get(url, Some(0..1))
.send()
.await
.map_err(|(e, d)| (HttpError::Request(e), d))?;
let headers = resp.headers();
let supports_range = headers
.get("content-range")
.is_ok_and(|v| v.trim_start().to_lowercase().starts_with("bytes 0-0/"));
Ok(supports_range)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::borrow::Cow;
use std::collections::HashMap;
struct MapHeaders(HashMap<String, String>);
impl HttpHeaders for MapHeaders {
type GetHeaderError = std::convert::Infallible;
fn get(&self, header: &str) -> Result<Cow<'_, str>, Self::GetHeaderError> {
Ok(Cow::Borrowed(self.0.get(header).map_or("", String::as_str)))
}
}
#[test]
fn get_filename_filename_star_not_double_decoded() {
let mut h = HashMap::new();
h.insert(
"content-disposition".to_string(),
"attachment; filename*=UTF-8''%25100".to_string(),
);
let headers = MapHeaders(h);
let url = Url::parse("http://example.com/").unwrap();
assert_eq!(get_filename(&headers, &url), "%100");
}
#[test]
fn get_filename_filename_star_no_percent_passthrough() {
let mut h = HashMap::new();
h.insert(
"content-disposition".to_string(),
"attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txt".to_string(),
);
let headers = MapHeaders(h);
let url = Url::parse("http://example.com/").unwrap();
assert_eq!(get_filename(&headers, &url), "测试.txt");
}
#[test]
fn get_filename_plain_filename_percent_stays_literal() {
let mut h = HashMap::new();
h.insert(
"content-disposition".to_string(),
"attachment; filename=\"%E6%B5%8B.txt\"".to_string(),
);
let headers = MapHeaders(h);
let url = Url::parse("http://example.com/").unwrap();
assert_eq!(get_filename(&headers, &url), "%E6%B5%8B.txt");
}
#[test]
fn get_filename_url_path_fallback_decodes_percent() {
let headers = MapHeaders(HashMap::new());
let url = Url::parse("http://example.com/dir/%E6%B5%8B%E8%AF%95.txt").unwrap();
assert_eq!(get_filename(&headers, &url), "测试.txt");
}
#[test]
fn get_filename_url_path_invalid_utf8_uses_replacement() {
let headers = MapHeaders(HashMap::new());
let url = Url::parse("http://example.com/%FF.txt").unwrap();
assert_eq!(get_filename(&headers, &url), "\u{FFFD}.txt");
}
#[test]
fn get_filename_whitespace_cd_falls_through_to_url() {
let mut h = HashMap::new();
h.insert(
"content-disposition".to_string(),
"attachment; filename=\" \"".to_string(),
);
let headers = MapHeaders(h);
let url = Url::parse("http://example.com/report.pdf").unwrap();
assert_eq!(get_filename(&headers, &url), "report.pdf");
}
#[test]
fn get_filename_empty_path_falls_back_to_host() {
let headers = MapHeaders(HashMap::new());
let url = Url::parse("http://cdn.example.com/").unwrap();
assert_eq!(get_filename(&headers, &url), "cdn_example_com");
}
#[test]
fn get_filename_path_segment_spaces_fall_back_to_host() {
let headers = MapHeaders(HashMap::new());
let url = Url::parse("http://cdn.example.com/%20%20").unwrap();
assert_eq!(get_filename(&headers, &url), "cdn_example_com");
}
}