diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 589eb90..2191b46 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Artwork a publisher sends without an image type, or as `application/octet-stream`, is shown when it is a JPEG, PNG, GIF or WebP, instead of initials (#279). - A 429 from TypeSafe holds every call back as it asks, instead of the pass going on asking (#277). - Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274). diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index df394b7..9a27006 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -1306,6 +1306,18 @@ mod tests { } } + #[test] + fn artwork_without_an_image_type_is_known_by_its_first_bytes() { + let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0]; + assert_eq!(image_kind("application/octet-stream", &jpeg).as_deref(), Some("image/jpeg")); + assert_eq!(image_kind("", b"\x89PNG\r\n").as_deref(), Some("image/png")); + assert_eq!(image_kind("", b"RIFF\0\0\0\0WEBPVP8").as_deref(), Some("image/webp")); + assert_eq!(image_kind("image/avif", b"").as_deref(), Some("image/avif")); + // A page, or an untyped SVG, stays refused. + assert_eq!(image_kind("text/html", &jpeg), None); + assert_eq!(image_kind("", b""), None); + } + #[test] fn a_signal_icon_is_a_style_and_a_plain_name() { assert_eq!(signal_icon(Some("solid/robot".into())).ok().flatten().as_deref(), Some("solid/robot")); @@ -2117,13 +2129,14 @@ async fn art(State(state): State, Query(q): Query) -> Respon crate::metrics::STATS.art_misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let got = async { 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 said = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned(); + anyhow::ensure!(said.starts_with("image/") || untyped(&said), "not an image: {said}"); let mut body = Vec::new(); while let Some(c) = r.chunk().await? { body.extend_from_slice(&c); anyhow::ensure!(body.len() <= MAX, "larger than {MAX} bytes"); } + let kind = image_kind(&said, &body).ok_or_else(|| anyhow::anyhow!("not an image: {said}"))?; anyhow::Ok((kind, body)) }; match got.await { @@ -2143,6 +2156,32 @@ async fn art(State(state): State, Query(q): Query) -> Respon } } +/// A type that says nothing about what the body is, which some publishers send with real artwork: +/// Spotify's CloudFront `application/octet-stream`, pippa.io none at all (#279). +fn untyped(said: &str) -> bool { + let said = said.split(';').next().unwrap_or("").trim(); + said.is_empty() || said.eq_ignore_ascii_case("application/octet-stream") || said.eq_ignore_ascii_case("binary/octet-stream") +} + +/// The artwork's type: the publisher's when it says image, else what the first bytes show, as a +/// browser would. Only raster formats are sniffed, never SVG, which could carry a script. +fn image_kind(said: &str, body: &[u8]) -> Option { + if said.starts_with("image/") { + return Some(said.to_owned()); + } + if !untyped(said) { + return None; + } + let kind = match body { + [0xFF, 0xD8, 0xFF, ..] => "image/jpeg", + [0x89, b'P', b'N', b'G', ..] => "image/png", + [b'G', b'I', b'F', b'8', ..] => "image/gif", + [b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => "image/webp", + _ => return None, + }; + Some(kind.to_owned()) +} + /// 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) -> Response {