The page loaded artwork from each publisher's server, or through /api/art, fetched every time, for http-only hosts. Nothing was kept, every visit asked every publisher, and artwork went when a publisher's server did. - The page draws every image a feed or item names from /api/art. The first time, iPX fetches it (only an address a feed or item names, only an image, up to 5 MB, within the feed timeout) and keeps it in art/ beside the database, under a hash of its address with its type beside it (src/art.rs). Later it comes from disk, which marks it as used. - art_cache_mb, a server setting on the admin page, 500 by default, caps what is kept: the sweep before each scan drops the least recently shown until it fits. 0 keeps nothing, and artwork is fetched through iPX each time. Settings saved before it get the default. - Served from iPX's own address, someone else's image must stay an image: nosniff, and a CSP with sandbox, so an SVG opened on its own runs no script as iPX. - The fixture server sends .jpg as image/jpeg, which /api/art requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
3.5 KiB
Rust
93 lines
3.5 KiB
Rust
//! 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 `<name>.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<u8>)> {
|
|
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);
|
|
}
|
|
}
|