use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use reqwest::Client;
use sha2::{Digest, Sha256};
use crate::fetcher::FetchError;
use crate::paths;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
const USER_AGENT: &str = concat!("splashboard/", env!("CARGO_PKG_VERSION"));
pub async fn download_to_cache(url: &str) -> Result<Option<PathBuf>, FetchError> {
let url = url.trim();
if url.is_empty() || !(url.starts_with("http://") || url.starts_with("https://")) {
return Ok(None);
}
let dir = thumbnail_dir()
.ok_or_else(|| FetchError::Failed("$HOME not available for thumbnail cache".into()))?;
std::fs::create_dir_all(&dir)
.map_err(|e| FetchError::Failed(format!("create thumbnail cache dir: {e}")))?;
let hash = hex(&Sha256::digest(url.as_bytes()));
if let Some(existing) = existing_cached(&dir, &hash) {
return Ok(Some(existing));
}
let bytes = fetch_bytes(url).await?;
let ext = image_extension(&bytes);
let path = dir.join(format!("{hash}.{ext}"));
std::fs::write(&path, &bytes)
.map_err(|e| FetchError::Failed(format!("write thumbnail: {e}")))?;
Ok(Some(path))
}
pub async fn download_many(urls: &[Option<String>]) -> Vec<Option<PathBuf>> {
let mut out = Vec::with_capacity(urls.len());
for maybe in urls {
let path = match maybe.as_deref() {
Some(u) => download_to_cache(u).await.ok().flatten(),
None => None,
};
out.push(path);
}
out
}
fn thumbnail_dir() -> Option<PathBuf> {
paths::cache_dir().map(|d| d.join("thumbnails"))
}
fn existing_cached(dir: &std::path::Path, hash: &str) -> Option<PathBuf> {
["png", "jpg", "webp", "gif"]
.iter()
.map(|ext| dir.join(format!("{hash}.{ext}")))
.find(|p| p.is_file())
}
fn http() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.gzip(true)
.build()
.expect("reqwest client should build with default config")
})
}
async fn fetch_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
let res = http()
.get(url)
.send()
.await
.map_err(|e| FetchError::Failed(format!("thumbnail request failed: {e}")))?;
let status = res.status();
if !status.is_success() {
return Err(FetchError::Failed(format!("thumbnail {status}")));
}
let bytes = res
.bytes()
.await
.map_err(|e| FetchError::Failed(format!("thumbnail body: {e}")))?;
if bytes.len() > MAX_BYTES {
return Err(FetchError::Failed(format!(
"thumbnail response too large ({} bytes, cap {MAX_BYTES})",
bytes.len()
)));
}
Ok(bytes.to_vec())
}
fn image_extension(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"png"
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
"jpg"
} else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
"webp"
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
"gif"
} else {
"png"
}
}
fn hex(digest: &[u8]) -> String {
digest.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn restore_home(previous: Option<String>) {
unsafe {
match previous {
Some(value) => std::env::set_var("SPLASHBOARD_HOME", value),
None => std::env::remove_var("SPLASHBOARD_HOME"),
}
}
}
fn serve_once(
status: &str,
content_type: &str,
body: &[u8],
) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let status = status.to_owned();
let content_type = content_type.to_owned();
let body = body.to_vec();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 1024];
let _ = stream.read(&mut request);
let header = format!(
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
stream.write_all(header.as_bytes()).unwrap();
stream.write_all(&body).unwrap();
stream.flush().unwrap();
});
(format!("http://{addr}/img.bin"), handle)
}
fn hash_of(url: &str) -> String {
hex(&Sha256::digest(url.as_bytes()))
}
#[test]
fn image_extension_detects_known_signatures() {
assert_eq!(image_extension(b"\x89PNG\r\n\x1a\nrest"), "png");
assert_eq!(image_extension(&[0xff, 0xd8, 0xff, 0xdb, 0]), "jpg");
assert_eq!(image_extension(b"GIF89a..."), "gif");
let mut webp = Vec::from(*b"RIFF");
webp.extend_from_slice(&[0, 0, 0, 0]);
webp.extend_from_slice(b"WEBPVP8 ");
assert_eq!(image_extension(&webp), "webp");
}
#[test]
fn image_extension_falls_back_to_png() {
assert_eq!(image_extension(&[0, 0, 0, 0]), "png");
assert_eq!(image_extension(&[]), "png");
}
#[tokio::test]
async fn empty_url_returns_none() {
assert!(download_to_cache("").await.unwrap().is_none());
assert!(download_to_cache(" ").await.unwrap().is_none());
}
#[tokio::test]
async fn non_http_url_returns_none() {
assert!(
download_to_cache("file:///etc/passwd")
.await
.unwrap()
.is_none()
);
assert!(
download_to_cache("ftp://example.com/x.png")
.await
.unwrap()
.is_none()
);
}
#[test]
fn hex_round_trips() {
assert_eq!(hex(&[0x12, 0xab, 0xff, 0x00]), "12abff00");
}
#[tokio::test]
async fn download_many_preserves_order_and_passes_none_through() {
let urls = vec![
None,
Some(String::new()),
Some("file:///etc/passwd".into()),
Some("not-a-url".into()),
];
let paths = download_many(&urls).await;
assert_eq!(paths.len(), 4);
assert!(paths.iter().all(|p| p.is_none()));
}
#[test]
fn existing_cached_returns_first_matching_extension() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let hash = "deadbeef";
assert!(existing_cached(dir, hash).is_none());
let jpg = dir.join(format!("{hash}.jpg"));
std::fs::write(&jpg, b"jpeg-bytes").unwrap();
assert_eq!(existing_cached(dir, hash), Some(jpg.clone()));
let png = dir.join(format!("{hash}.png"));
std::fs::write(&png, b"png-bytes").unwrap();
assert_eq!(existing_cached(dir, hash), Some(png));
}
#[tokio::test]
async fn download_to_cache_no_home_yields_failed_error() {
let res = download_to_cache("ftp://example.com/x.png").await.unwrap();
assert!(res.is_none());
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn download_to_cache_returns_existing_cached_path_without_network() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", tmp.path()) };
let url = "https://example.com/cached.png";
let dir = tmp.path().join("cache").join("thumbnails");
std::fs::create_dir_all(&dir).unwrap();
let cached = dir.join(format!("{}.png", hash_of(url)));
std::fs::write(&cached, b"pretend-png").unwrap();
let result = download_to_cache(url).await.unwrap();
restore_home(previous);
assert_eq!(result, Some(cached));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn download_to_cache_writes_new_thumbnail_to_disk() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", tmp.path()) };
let payload = b"\x89PNG\r\n\x1a\nfreshly-downloaded";
let (url, server) = serve_once("200 OK", "image/png", payload);
let result = download_to_cache(&url).await.unwrap();
server.join().unwrap();
restore_home(previous);
let path = result.expect("happy path should yield a cached path");
assert_eq!(path.extension().unwrap(), "png");
let written = std::fs::read(&path).unwrap();
assert_eq!(written, payload);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn download_to_cache_propagates_http_error_status() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", tmp.path()) };
let (url, server) = serve_once("404 Not Found", "text/plain", b"missing");
let err = download_to_cache(&url).await.unwrap_err();
server.join().unwrap();
restore_home(previous);
let FetchError::Failed(msg) = err else {
panic!("expected Failed, got {err:?}");
};
assert!(msg.contains("thumbnail 404"), "unexpected error: {msg}");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn download_to_cache_rejects_oversized_payload() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", tmp.path()) };
let oversize = vec![b'x'; MAX_BYTES + 1];
let (url, server) = serve_once("200 OK", "image/png", &oversize);
let err = download_to_cache(&url).await.unwrap_err();
server.join().unwrap();
restore_home(previous);
let FetchError::Failed(msg) = err else {
panic!("expected Failed, got {err:?}");
};
assert!(
msg.contains("thumbnail response too large"),
"unexpected error: {msg}"
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn download_many_collapses_individual_failures_to_none() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", tmp.path()) };
let (bad_url, server) = serve_once("500 Internal Server Error", "text/plain", b"boom");
let urls = vec![None, Some(bad_url), Some("not-a-url".into())];
let paths = download_many(&urls).await;
server.join().unwrap();
restore_home(previous);
assert_eq!(paths.len(), 3);
assert!(paths.iter().all(|p| p.is_none()));
}
}