diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 74d3139..bcf541a 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Feeds, episodes, artwork and `.torrent` files are no longer fetched from internal addresses (loopback, the LAN, Docker's networks), redirects included, unless config.toml's `fetch_internal` lists them. Adding a feed could read what a service inside the network answered (#320). - HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351). - A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321). +- A show kept from a private OPML or Patreon list stays private, with the list's username and password, once the list is removed or left. It became public and was shared to the directory (#332). ## [1.4.0] - 2026-10-19 diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index e460d35..131c6d3 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -992,17 +992,17 @@ fn url_stem(url: &str) -> String { async fn rm(ctx: &Ctx, feed: &str) -> Result<()> { let mut cfg = ctx.edit_cfg().await; - if cfg.feeds.remove(feed).is_none() { + let Some(parent) = cfg.feeds.remove(feed) else { // Derived from an OPML: drop it here, though the subscription will list it again // on the next read unless the OPML itself goes. ctx.db.drop_managed(feed).await?; println!("removed {feed}; it came from an OPML subscription and may return on the next read"); return Ok(()); - } + }; ctx.store_cfg(cfg).await?; // State and files stay: re-adding the feed should not re-download its back catalogue. println!("removed {feed}; downloads and history kept"); - retire_group(ctx, feed).await?; + retire_group(ctx, feed, Some(&parent)).await?; Ok(()) } @@ -1187,15 +1187,17 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> { } let mut cfg = ctx.edit_cfg().await; // Every one removed, so not `any`, which would stop at the first. - let mut removed = false; + let mut removed = std::collections::HashMap::new(); for id in &stale { - removed |= cfg.feeds.remove(id).is_some(); + if let Some(f) = cfg.feeds.remove(id) { + removed.insert(id.clone(), f); + } } - if removed { + if !removed.is_empty() { ctx.store_cfg(cfg).await?; } for id in &stale { - retire_group(ctx, id).await?; + retire_group(ctx, id, removed.get(id)).await?; ctx.db.forget_feed(id).await?; tracing::info!( feed = id, @@ -1479,13 +1481,13 @@ pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> { if cfg.feeds.get(id).is_some_and(|f| f.listed) { return Ok(()); } - if cfg.feeds.remove(id).is_none() { + let Some(parent) = cfg.feeds.remove(id) else { // A derived feed: forget it here, though the OPML will list it again on the next // read unless you unsubscribe from the OPML itself. return ctx.db.drop_managed(id).await; - } + }; ctx.store_cfg(cfg).await?; - retire_group(ctx, id).await + retire_group(ctx, id, Some(&parent)).await } /// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys @@ -1510,15 +1512,16 @@ pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> { /// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent /// itself is removed, since `subscriptions()` would otherwise keep scanning them under a /// fallback policy meant for a feed with no parent at all. A feed promoted to config is not -/// derived any more, so it is only unmanaged. -pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> { +/// derived any more, so it is only unmanaged. `parent` is the list's settings as they were before +/// it was removed, None when that is no longer known. +pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Feed>) -> Result<()> { let cfg = ctx.cfg(); for m in ctx.db.managed_feeds().await?.into_iter().filter(|m| m.group_id == parent_id) { if cfg.feeds.contains_key(&m.id) { // Scanned from its config entry and still read. Dropped as derived, its stored // entries would go with it: davewiner's 11 were promoted without being unmanaged. ctx.db.unmanage(&m.id).await?; - } else if promote_kept(ctx, &m).await? { + } else if promote_kept(ctx, &m, parent).await? { tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now"); } else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 { ctx.db.set_orphaned(&m.id, true).await?; @@ -1532,31 +1535,33 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> { /// Makes a feed from an OPML or Patreon list a feed of its own, for those who kept it (#280): a /// catalogue entry in no group, which the list no longer adds or takes away. Whoever had it only /// through the list, without keeping it, loses it, as they would have when the list dropped it. -/// False, changing nothing, when nobody kept it. -async fn promote_kept(ctx: &Ctx, m: &db::Managed) -> Result { +/// False, changing nothing, when nobody kept it. `base` is the list's settings: most callers have +/// removed the list from config by now, so they pass them. +async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&config::Feed>) -> Result { let subs = ctx.db.subscribers_of(&m.id).await?; if !subs.iter().any(|(_, kept)| *kept) { return Ok(false); } let mut cfg = ctx.edit_cfg().await; - // The list's own settings carry over while it is there; its folder and group do not. - let base = cfg.feeds.get(&m.group_id).cloned(); + // The list's own settings carry over; its folder and group do not. cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed { url: m.url.clone(), folder: None, group: None, - media_types: base.as_ref().and_then(|p| p.media_types.clone()), - schedule: base.as_ref().and_then(|p| p.schedule.clone()), - keywords: base.as_ref().map(|p| p.keywords.clone()).unwrap_or_default(), - allow_explicit: base.as_ref().is_some_and(|p| p.allow_explicit), - auto_download: base.as_ref().is_none_or(|p| p.auto_download), - max_new_per_check: base.as_ref().and_then(|p| p.max_new_per_check), - username: base.as_ref().and_then(|p| p.username.clone()), - password: base.as_ref().and_then(|p| p.password.clone()), - password_env: base.as_ref().and_then(|p| p.password_env.clone()), + media_types: base.and_then(|p| p.media_types.clone()), + schedule: base.and_then(|p| p.schedule.clone()), + keywords: base.map(|p| p.keywords.clone()).unwrap_or_default(), + allow_explicit: base.is_some_and(|p| p.allow_explicit), + auto_download: base.is_none_or(|p| p.auto_download), + max_new_per_check: base.and_then(|p| p.max_new_per_check), + username: base.and_then(|p| p.username.clone()), + password: base.and_then(|p| p.password.clone()), + password_env: base.and_then(|p| p.password_env.clone()), category: None, listed: false, - private: base.as_ref().is_some_and(|p| p.private), + // Looked up after the list was removed, private was always false, and a private OPML's + // kept show went to the directory (#332). A list no longer known may have been private. + private: base.is_none_or(|p| p.private), }); ctx.store_cfg(cfg).await?; ctx.db.unmanage(&m.id).await?; @@ -1579,7 +1584,7 @@ async fn retire_stranded(ctx: &Ctx) -> Result { let stranded: std::collections::BTreeSet<&str> = before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect(); for group in stranded { - retire_group(ctx, group).await?; + retire_group(ctx, group, None).await?; } Ok(before.len() - ctx.db.managed_feeds().await?.len()) } @@ -2135,11 +2140,12 @@ async fn sync_group( // Anything in this group the OPML no longer lists. let mut removed = 0; let mut kept = 0; + let parent = ctx.cfg().feeds.get(parent_id).cloned(); for m in existing.iter().filter(|m| m.group_id == parent_id) { if listed.iter().any(|(_, u)| u == &m.url) { continue; } - if promote_kept(ctx, m).await? { + if promote_kept(ctx, m, parent.as_ref()).await? { kept += 1; tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now"); continue; @@ -3065,7 +3071,7 @@ mod tests { ctx.db.record_enclosure("has-file", "g1", &enc).await.unwrap(); ctx.db.mark_downloaded(&enc.url, std::path::Path::new("/downloads/ep.mp3"), 1).await.unwrap(); - retire_group(&ctx, "parent").await.unwrap(); + retire_group(&ctx, "parent", None).await.unwrap(); let managed = ctx.db.managed_feeds().await.unwrap(); assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten"); @@ -3076,7 +3082,7 @@ mod tests { #[tokio::test] async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() { let mut cfg = config::Config::default(); - cfg.feeds.insert("opml".into(), feed()); + cfg.feeds.insert("opml".into(), config::Feed { private: true, ..feed() }); let ctx = Arc::new(test_ctx(cfg).await); let (me, you) = ( ctx.db.create_user("me", None, false).await.unwrap(), @@ -3097,7 +3103,7 @@ mod tests { let parent = ctx.cfg().feeds["opml"].clone(); sync_group(&ctx, "opml", &parent, &[("b".into(), "http://x/b.xml".into())]).await.unwrap(); let a = ctx.cfg().feeds.get("a").cloned().expect("a is in the catalogue"); - assert_eq!((a.url.as_str(), a.group), ("http://x/a.xml", None)); + assert_eq!((a.url.as_str(), a.group, a.private), ("http://x/a.xml", None, true)); assert!(!ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "a")); assert!(ctx.db.subscription(me, "a").await.unwrap().is_some()); assert!(ctx.db.subscription(you, "a").await.unwrap().is_none()); @@ -3108,7 +3114,8 @@ mod tests { assert!(ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "b")); // Once nobody reads it, b is mine alone, and yours goes with the OPML. leave(&ctx, you, "opml").await.unwrap(); - assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none())); + // Still private: the OPML is gone from config by the time b is promoted (#332). + assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none() && f.private)); assert!(ctx.db.subscription(me, "b").await.unwrap().is_some()); assert!(ctx.db.subscription(you, "b").await.unwrap().is_none()); }