A kept show keeps its list's private mark and credentials (#332)

promote_kept copied the list's settings from the current config, but
leave, rm and the directory cleanup remove the list from config before
calling retire_group, so the lookup found nothing: the promoted show
got private = false and no username or password, and the next snapshot
sent its URL to the directory. Only sync_group, which promotes while
the list is still there, kept them.

The callers now pass the list's settings as they were before removal.
Where they are no longer known (retire_stranded, for a list removed
long ago), the promoted show is private: a URL kept back by mistake
can be shared by hand, one shared by mistake cannot be taken back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 13:59:02 +00:00
parent cc3b8742bc
commit 4f7dab9c04
2 changed files with 41 additions and 33 deletions

View File

@@ -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

View File

@@ -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<bool> {
/// 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<bool> {
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<usize> {
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());
}