//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked //! of each publisher's server for every visit, and still there when a publisher is not. use std::path::{Path, PathBuf}; pub fn dir() -> PathBuf { crate::config::data_dir().join("art") } /// Where an image's bytes are kept; its content type is beside it, in `.type`. // ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which // only makes the cache miss once and refill; a collision among a few thousand images is about // one in 10^12. Move to a SHA if either ever matters. pub fn path(url: &str) -> PathBuf { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); url.hash(&mut h); dir().join(format!("{:016x}", h.finish())) } /// A kept image and its type, marked as just used, which is what keeps it from being trimmed. pub fn read(file: &Path) -> Option<(String, Vec)> { let kind = std::fs::read_to_string(file.with_extension("type")).ok()?; let body = std::fs::read(file).ok()?; if let Ok(f) = std::fs::File::options().write(true).open(file) { let _ = f.set_modified(std::time::SystemTime::now()); } Some((kind, body)) } /// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile /// never reads half of one. pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> { std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?; let tmp = file.with_extension("part"); std::fs::write(&tmp, body)?; std::fs::write(file.with_extension("type"), kind)?; std::fs::rename(&tmp, file) } /// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it. /// Returns how many went. pub fn trim(dir: &Path, limit: u64) -> usize { let Ok(entries) = std::fs::read_dir(dir) else { return 0 }; let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries .flatten() .map(|e| e.path()) .filter(|p| p.extension().is_none()) .filter_map(|p| { let m = p.metadata().ok()?; Some((m.modified().ok()?, m.len(), p)) }) .collect(); let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum(); kept.sort(); let mut gone = 0; for (_, n, p) in kept { if total <= limit { break; } let _ = std::fs::remove_file(p.with_extension("type")); if std::fs::remove_file(&p).is_ok() { total -= n; gone += 1; } } gone } #[cfg(test)] mod tests { use super::*; #[test] fn the_least_recently_used_go_first_until_it_fits() { let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] { let f = d.join(name); write(&f, "image/png", &[0; 1000]).unwrap(); let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age); std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap(); } // Shown just now: no longer the oldest. assert_eq!(read(&d.join("old")).unwrap().0, "image/png"); assert_eq!(trim(&d, 2000), 1); assert!(!d.join("mid").exists() && !d.join("mid.type").exists()); assert!(d.join("old").exists() && d.join("new").exists()); assert_eq!(trim(&d, 0), 2); let _ = std::fs::remove_dir_all(&d); } }