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 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:19:06 +00:00
parent 928cbaf8f4
commit 36b16c7f5d
4 changed files with 62 additions and 2 deletions

View File

@@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### 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 - 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. 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. - A feed whose own artwork is missing shows its website's icon instead of nothing.

View File

@@ -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. /// 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<bool> {
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<bool> { pub async fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
let r = subscriptions::Entity::update_many() let r = subscriptions::Entity::update_many()
.col_expr(subscriptions::Column::Pinned, Expr::val(on).into()) .col_expr(subscriptions::Column::Pinned, Expr::val(on).into())
@@ -1800,6 +1808,19 @@ mod tests {
assert!(!order_sql("x'; --", "asc").contains("x'")); 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] #[tokio::test]
async fn deleting_a_shared_file_asks_about_everyone_else() { async fn deleting_a_shared_file_asks_about_everyone_else() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();

View File

@@ -64,6 +64,7 @@ pub fn router(state: WebState) -> Router {
.route("/admin", get(admin_page)) .route("/admin", get(admin_page))
.route("/admin.js", get(admin_js)) .route("/admin.js", get(admin_js))
.route("/media/{id}", get(media)) .route("/media/{id}", get(media))
.route("/api/art", get(art))
.layer(middleware::from_fn_with_state(state.clone(), auth)) .layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer. // Signing in cannot require being signed in, so these sit outside the auth layer.
.route("/login", get(login_page)) .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<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) {
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)] #[derive(Deserialize)]
struct Position { struct Position {
secs: i64, secs: i64,

View File

@@ -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){ function tileHTML(name,cls){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`; return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
} }
/// 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){ function artHTML(url: string | null, name: string, cls?: string){
return url return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">` ? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
: tileHTML(name,cls); : tileHTML(name,cls);
} }
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own. /// 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); 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. // Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=> return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`; `<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
} }
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away. /// The sidebar slides over the page on a phone, so it needs a scrim to tap away.