Artwork without an image type is known by its first bytes (#279)

Spotify's CloudFront serves some covers as application/octet-stream and
pippa.io sends no Content-Type at all; both are JPEGs, which browsers sniff
and show, but /api/art refused them with a 502 and the podcast showed its
initials. An untyped or octet-stream answer is now read and checked for
JPEG, PNG, GIF or WebP magic; anything else, SVG included, is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:26:01 +00:00
parent 41fc2e3933
commit 36a820f283
2 changed files with 42 additions and 2 deletions

View File

@@ -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).

View File

@@ -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"<svg onload=alert(1)>"), 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<WebState>, Query(q): Query<ArtQuery>) -> 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<WebState>, Query(q): Query<ArtQuery>) -> 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<String> {
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<u8>) -> Response {