diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 9eec4d8..2f14ee2 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Artwork comes quickly: an image already kept is served without asking the database again, and the database finds which feed names an image by an index instead of reading every item, which took over half a second an image (#272). +- The Directory shows the artwork of podcasts nobody on this server subscribes to, where it drew their initials (#273). - 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/db.rs b/crates/ipx/src/db.rs index d5e8765..dea0b43 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -126,6 +126,15 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> { // An expression index has no statistics until its table is analysed, and without them // Postgres guessed a third of entries were recent and read them all, entries_recent unused // (#274). Analysed once when they are missing; autovacuum keeps them after. + // `/api/art` asks whether a feed or item names an image before serving it, and without an + // index that read all of entries, 571 ms an image, 50 at once on a page (#272). A hash index + // on Postgres, whose btree refuses a key over about 2.7 KB, longer than some image addresses. + orm.execute_unprepared(match orm.get_database_backend() { + sea_orm::DbBackend::Postgres => "CREATE INDEX IF NOT EXISTS entries_by_image ON entries USING hash (image)", + _ => "CREATE INDEX IF NOT EXISTS entries_by_image ON entries (image)", + }) + .await + .context("indexing entries.image")?; if orm.get_database_backend() == sea_orm::DbBackend::Postgres { let has = Statement::from_string( sea_orm::DbBackend::Postgres, diff --git a/crates/ipx/src/directory.rs b/crates/ipx/src/directory.rs index 8b92b39..b814716 100644 --- a/crates/ipx/src/directory.rs +++ b/crates/ipx/src/directory.rs @@ -117,15 +117,37 @@ pub async fn me(ctx: &Ctx) -> Result { } pub async fn feeds(ctx: &Ctx) -> Result, DirError> { - call(ctx, reqwest::Method::GET, "/v1/feeds", None).await + let got: Vec = call(ctx, reqwest::Method::GET, "/v1/feeds", None).await?; + note_art(got.iter()); + Ok(got) } pub async fn popular(ctx: &Ctx) -> Result, DirError> { - call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await + let got: Vec = call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await?; + note_art(got.iter()); + Ok(got) } pub async fn page(ctx: &Ctx, id: &str) -> Result { - call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await + let got: FeedPage = call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await?; + note_art(std::iter::once(&got.listing)); + Ok(got) +} + +/// Artwork the directory has listed, which `/api/art` serves though no feed here names it: the +/// Directory shows podcasts nobody here subscribes to, and drew their initials instead (#273). +/// +/// shortcut: in memory, one address a listed feed, forgotten on a restart until the Directory is +/// next read, which the page does when it loads; a table if that ever shows as initials. +static LISTED_ART: std::sync::LazyLock>> = std::sync::LazyLock::new(Default::default); + +fn note_art<'a>(listings: impl Iterator) { + let mut art = LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()); + art.extend(listings.filter_map(|l| l.image.clone())); +} + +pub fn listed_art(url: &str) -> bool { + LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()).contains(url) } // Administering the directory (#206): each needs an rwa token, which the directory checks. @@ -279,6 +301,23 @@ fn resend(last: Option<&(Vec, std::time::Instant)>, key: &[u8]) -> bool { mod tests { use super::*; + #[test] + fn artwork_the_directory_listed_is_let_through_and_nothing_else() { + let listing = |image: Option<&str>| Listing { + id: "x".into(), + url: "https://x.example/feed".into(), + title: None, + image: image.map(str::to_owned), + category: None, + subcategory: None, + podcast: true, + subscribers: 1, + }; + note_art([listing(Some("https://img.example/cover.jpg")), listing(None)].iter()); + assert!(listed_art("https://img.example/cover.jpg")); + assert!(!listed_art("http://192.168.1.1/admin")); + } + fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub { let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap(); edit(&mut cfg); diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 6bc162c..9e2f0ee 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -2120,15 +2120,21 @@ async fn art(State(state): State, Query(q): Query) -> Respon // 5 MB turned away the Glass Cannon Network's covers, 7 MB PNGs (#169). const MAX: usize = 20 << 20; let web = q.u.starts_with("http://") || q.u.starts_with("https://"); - if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) { + if !web { return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response(); } + // Kept only once it was let through below, so a kept image needs no asking again (#272). let keep = state.ctx.cfg().general.art_cache_mb > 0; let file = crate::art::path(&q.u); if keep && let Some((kind, body)) = crate::art::read(&file) { crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); return art_response(kind, body); } + // Only what a feed here or the directory names: anything else would make ipx fetch any + // address it is given. The Directory shows podcasts nobody here subscribes to (#273). + if !crate::directory::listed_art(&q.u) && !state.ctx.db.names_image(&q.u).await.unwrap_or(false) { + return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response(); + } if let Some(why) = crate::art::failed(&q.u) { return (StatusCode::BAD_GATEWAY, why).into_response(); }