From 36b16c7f5df7679912b3547b75a426cba687556f Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 29 Sep 2026 16:19:06 +0000 Subject: [PATCH] Fetch artwork offered only over http for the https page (#90) Through ipodderx.sdf1.net the page is https, the browser upgrades an http:// image to https, and a host with no https, such as The Secret Cabal's CDN, answers nothing, so no artwork. On an https page, the page now asks /api/art for those, and ipx fetches them. It only fetches an address some feed or entry names as its artwork, and only an image, up to 5 MB, so the route cannot be pointed at anything else on the network. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/db.rs | 21 +++++++++++++++++++++ src/web.rs | 33 +++++++++++++++++++++++++++++++++ web/src/util.ts | 9 +++++++-- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db45ba..7e90363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Artwork a feed offers only over plain http shows on the https site too. - A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of its counts, and its artwork greyed out once it has failed for a day. - A feed whose own artwork is missing shows its website's icon instead of nothing. diff --git a/src/db.rs b/src/db.rs index dd4d041..3bc7fd6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1313,6 +1313,14 @@ impl Db { } /// False when they do not subscribe to it, since there is then no row in their list to pin. + /// Whether a feed or an entry names `url` as its artwork: the only addresses /api/art will + /// fetch, so the web server cannot be pointed at anything else. + // ponytail: entries.image is unindexed, a scan per http:// image; index it if that shows up. + pub async fn names_image(&self, url: &str) -> Result { + Ok(feeds::Entity::find().filter(feeds::Column::Image.eq(url)).count(&self.orm).await? > 0 + || entries::Entity::find().filter(entries::Column::Image.eq(url)).count(&self.orm).await? > 0) + } + pub async fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result { let r = subscriptions::Entity::update_many() .col_expr(subscriptions::Column::Pinned, Expr::val(on).into()) @@ -1800,6 +1808,19 @@ mod tests { assert!(!order_sql("x'; --", "asc").contains("x'")); } + #[tokio::test] + async fn only_artwork_a_feed_names_is_fetched_for_the_page() { + let db = Db::memory().await.unwrap(); + db.exec_for_test( + "INSERT INTO feeds (id, url, image) VALUES ('f','u','http://cdn.example/show.jpg'); + INSERT INTO entries (feed_id, guid, first_seen, image) VALUES ('f','a',0,'http://cdn.example/ep.jpg');", + ).await + .unwrap(); + assert!(db.names_image("http://cdn.example/show.jpg").await.unwrap()); + assert!(db.names_image("http://cdn.example/ep.jpg").await.unwrap()); + assert!(!db.names_image("http://192.168.1.1/admin").await.unwrap()); + } + #[tokio::test] async fn deleting_a_shared_file_asks_about_everyone_else() { let db = Db::memory().await.unwrap(); diff --git a/src/web.rs b/src/web.rs index 2b1ac92..7ea5890 100644 --- a/src/web.rs +++ b/src/web.rs @@ -64,6 +64,7 @@ pub fn router(state: WebState) -> Router { .route("/admin", get(admin_page)) .route("/admin.js", get(admin_js)) .route("/media/{id}", get(media)) + .route("/api/art", get(art)) .layer(middleware::from_fn_with_state(state.clone(), auth)) // Signing in cannot require being signed in, so these sit outside the auth layer. .route("/login", get(login_page)) @@ -1676,6 +1677,38 @@ async fn media( } } +#[derive(Deserialize)] +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). +async fn art(State(state): State, Query(q): Query) -> Response { + const MAX: usize = 5 << 20; + if !q.u.starts_with("http://") || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) { + return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response(); + } + let got = async { + let mut r = state.ctx.client.get(&q.u).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(); + while let Some(c) = r.chunk().await? { + body.extend_from_slice(&c); + anyhow::ensure!(body.len() <= MAX, "larger than {MAX} bytes"); + } + anyhow::Ok((kind, body)) + }; + match got.await { + Ok((kind, body)) => { + ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "private, max-age=86400".into())], body).into_response() + } + Err(e) => (StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), + } +} + #[derive(Deserialize)] struct Position { secs: i64, diff --git a/web/src/util.ts b/web/src/util.ts index 2339449..916b61c 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -145,9 +145,14 @@ const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>> function tileHTML(name,cls){ return `
${esc(initials(name))}
`; } +/// On the https page, the browser upgrades an http:// image to https, and a host that has no +/// https shows nothing (issue #90); ipx fetches those itself. +function artSrc(url: string){ + return location.protocol==='https:'&&/^http:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url; +} function artHTML(url: string | null, name: string, cls?: string){ return url - ? `` + ? `` : tileHTML(name,cls); } /// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own. @@ -156,7 +161,7 @@ function folderArt(f,kids){ if(art.length<4) return artHTML(f.image,f.title||f.id); // Tinted underneath, so art that fails to load leaves colour behind rather than a hole. return `
${art.map(c=> - ``).join('')}
`; + ``).join('')}`; } /// The sidebar slides over the page on a phone, so it needs a scrim to tap away.