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:
45
src/web.rs
45
src/web.rs
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user