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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user