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:
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user