Keep artwork on disk and serve every image from iPX (#111)

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>
This commit is contained in:
2026-10-02 13:50:46 +00:00
parent 38ebc7b02b
commit a00516687a
11 changed files with 196 additions and 15 deletions

View File

@@ -1730,16 +1730,25 @@ struct ArtQuery {
u: String,
}
/// Artwork a feed names on plain http, fetched here for the https page. The browser upgrades an
/// http image on an https page to https, and a host with no https, such as The Secret Cabal's
/// CDN, then answers nothing (issue #90).
/// Artwork, from iPX: the page asks here for every image a feed or item names, and the first
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
/// server. Only an address some feed or item names as its artwork, and only an image up to
/// 5 MB, so the route cannot be pointed at anything else. It began as a way round http-only
/// artwork on the https page (#90).
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
const MAX: usize = 5 << 20;
if !q.u.starts_with("http://") || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
let web = q.u.starts_with("http://") || q.u.starts_with("https://");
if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
}
let keep = state.ctx.cfg().general.art_cache_mb > 0;
let file = crate::art::path(&q.u);
if keep && let Some((kind, body)) = crate::art::read(&file) {
return art_response(kind, body);
}
let got = async {
let mut r = state.ctx.client.get(&q.u).send().await?.error_for_status()?;
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?;
let kind = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
anyhow::ensure!(kind.starts_with("image/"), "not an image: {kind}");
let mut body = Vec::new();
@@ -1751,12 +1760,30 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
};
match got.await {
Ok((kind, body)) => {
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "private, max-age=86400".into())], body).into_response()
if keep && let Err(e) = crate::art::write(&file, &kind, &body) {
tracing::debug!(error = %e, "could not keep the artwork");
}
art_response(kind, body)
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(),
}
}
/// An image from someone else's server, served from iPX's own address: it must stay an image.
/// An SVG opened on its own would otherwise run its script as iPX's page, with its cookies.
fn art_response(kind: String, body: Vec<u8>) -> Response {
(
[
(header::CONTENT_TYPE, kind),
(header::CACHE_CONTROL, "private, max-age=2592000".into()),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".into()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'; sandbox".into()),
],
body,
)
.into_response()
}
#[derive(Deserialize)]
struct Position {
secs: i64,
@@ -1922,6 +1949,7 @@ struct Settings {
download_dir: String,
max_total_gb: f64,
max_age_days: u64,
art_cache_mb: u64,
}
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
@@ -1934,6 +1962,7 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
download_dir: cfg.general.download_dir.display().to_string(),
max_total_gb: cfg.general.max_total_gb,
max_age_days: cfg.general.max_age_days,
art_cache_mb: cfg.general.art_cache_mb,
})
}
@@ -1944,6 +1973,7 @@ struct SettingsPatch {
media_types: Option<Vec<String>>,
max_total_gb: Option<f64>,
max_age_days: Option<u64>,
art_cache_mb: Option<u64>,
}
async fn patch_settings(
@@ -1980,6 +2010,9 @@ async fn patch_settings(
if let Some(v) = body.max_age_days {
cfg.general.max_age_days = v;
}
if let Some(v) = body.art_cache_mb {
cfg.general.art_cache_mb = v;
}
state.ctx.store_cfg(cfg).await?;
Ok(StatusCode::NO_CONTENT)
}