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).
|
- 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).
|
- 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 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
|
## [1.4.0] - 2026-10-19
|
||||||
|
|
||||||
|
|||||||
@@ -992,17 +992,17 @@ fn url_stem(url: &str) -> String {
|
|||||||
|
|
||||||
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
||||||
let mut cfg = ctx.edit_cfg().await;
|
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
|
// Derived from an OPML: drop it here, though the subscription will list it again
|
||||||
// on the next read unless the OPML itself goes.
|
// on the next read unless the OPML itself goes.
|
||||||
ctx.db.drop_managed(feed).await?;
|
ctx.db.drop_managed(feed).await?;
|
||||||
println!("removed {feed}; it came from an OPML subscription and may return on the next read");
|
println!("removed {feed}; it came from an OPML subscription and may return on the next read");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
};
|
||||||
ctx.store_cfg(cfg).await?;
|
ctx.store_cfg(cfg).await?;
|
||||||
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
||||||
println!("removed {feed}; downloads and history kept");
|
println!("removed {feed}; downloads and history kept");
|
||||||
retire_group(ctx, feed).await?;
|
retire_group(ctx, feed, Some(&parent)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,15 +1187,17 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
|
|||||||
}
|
}
|
||||||
let mut cfg = ctx.edit_cfg().await;
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
// Every one removed, so not `any`, which would stop at the first.
|
// 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 {
|
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?;
|
ctx.store_cfg(cfg).await?;
|
||||||
}
|
}
|
||||||
for id in &stale {
|
for id in &stale {
|
||||||
retire_group(ctx, id).await?;
|
retire_group(ctx, id, removed.get(id)).await?;
|
||||||
ctx.db.forget_feed(id).await?;
|
ctx.db.forget_feed(id).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
feed = id,
|
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) {
|
if cfg.feeds.get(id).is_some_and(|f| f.listed) {
|
||||||
return Ok(());
|
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
|
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||||
// read unless you unsubscribe from the OPML itself.
|
// read unless you unsubscribe from the OPML itself.
|
||||||
return ctx.db.drop_managed(id).await;
|
return ctx.db.drop_managed(id).await;
|
||||||
}
|
};
|
||||||
ctx.store_cfg(cfg).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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// derived any more, so it is only unmanaged. `parent` is the list's settings as they were before
|
||||||
pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
|
/// 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();
|
let cfg = ctx.cfg();
|
||||||
for m in ctx.db.managed_feeds().await?.into_iter().filter(|m| m.group_id == parent_id) {
|
for m in ctx.db.managed_feeds().await?.into_iter().filter(|m| m.group_id == parent_id) {
|
||||||
if cfg.feeds.contains_key(&m.id) {
|
if cfg.feeds.contains_key(&m.id) {
|
||||||
// Scanned from its config entry and still read. Dropped as derived, its stored
|
// 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.
|
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
||||||
ctx.db.unmanage(&m.id).await?;
|
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");
|
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 {
|
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||||
ctx.db.set_orphaned(&m.id, true).await?;
|
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
|
/// 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
|
/// 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.
|
/// through the list, without keeping it, loses it, as they would have when the list dropped it.
|
||||||
/// False, changing nothing, when nobody kept it.
|
/// False, changing nothing, when nobody kept it. `base` is the list's settings: most callers have
|
||||||
async fn promote_kept(ctx: &Ctx, m: &db::Managed) -> Result<bool> {
|
/// 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?;
|
let subs = ctx.db.subscribers_of(&m.id).await?;
|
||||||
if !subs.iter().any(|(_, kept)| *kept) {
|
if !subs.iter().any(|(_, kept)| *kept) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let mut cfg = ctx.edit_cfg().await;
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
// The list's own settings carry over while it is there; its folder and group do not.
|
// The list's own settings carry over; its folder and group do not.
|
||||||
let base = cfg.feeds.get(&m.group_id).cloned();
|
|
||||||
cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
|
cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
|
||||||
url: m.url.clone(),
|
url: m.url.clone(),
|
||||||
folder: None,
|
folder: None,
|
||||||
group: None,
|
group: None,
|
||||||
media_types: base.as_ref().and_then(|p| p.media_types.clone()),
|
media_types: base.and_then(|p| p.media_types.clone()),
|
||||||
schedule: base.as_ref().and_then(|p| p.schedule.clone()),
|
schedule: base.and_then(|p| p.schedule.clone()),
|
||||||
keywords: base.as_ref().map(|p| p.keywords.clone()).unwrap_or_default(),
|
keywords: base.map(|p| p.keywords.clone()).unwrap_or_default(),
|
||||||
allow_explicit: base.as_ref().is_some_and(|p| p.allow_explicit),
|
allow_explicit: base.is_some_and(|p| p.allow_explicit),
|
||||||
auto_download: base.as_ref().is_none_or(|p| p.auto_download),
|
auto_download: base.is_none_or(|p| p.auto_download),
|
||||||
max_new_per_check: base.as_ref().and_then(|p| p.max_new_per_check),
|
max_new_per_check: base.and_then(|p| p.max_new_per_check),
|
||||||
username: base.as_ref().and_then(|p| p.username.clone()),
|
username: base.and_then(|p| p.username.clone()),
|
||||||
password: base.as_ref().and_then(|p| p.password.clone()),
|
password: base.and_then(|p| p.password.clone()),
|
||||||
password_env: base.as_ref().and_then(|p| p.password_env.clone()),
|
password_env: base.and_then(|p| p.password_env.clone()),
|
||||||
category: None,
|
category: None,
|
||||||
listed: false,
|
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.store_cfg(cfg).await?;
|
||||||
ctx.db.unmanage(&m.id).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> =
|
let stranded: std::collections::BTreeSet<&str> =
|
||||||
before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect();
|
before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect();
|
||||||
for group in stranded {
|
for group in stranded {
|
||||||
retire_group(ctx, group).await?;
|
retire_group(ctx, group, None).await?;
|
||||||
}
|
}
|
||||||
Ok(before.len() - ctx.db.managed_feeds().await?.len())
|
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.
|
// Anything in this group the OPML no longer lists.
|
||||||
let mut removed = 0;
|
let mut removed = 0;
|
||||||
let mut kept = 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) {
|
for m in existing.iter().filter(|m| m.group_id == parent_id) {
|
||||||
if listed.iter().any(|(_, u)| u == &m.url) {
|
if listed.iter().any(|(_, u)| u == &m.url) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if promote_kept(ctx, m).await? {
|
if promote_kept(ctx, m, parent.as_ref()).await? {
|
||||||
kept += 1;
|
kept += 1;
|
||||||
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
|
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
|
||||||
continue;
|
continue;
|
||||||
@@ -3065,7 +3071,7 @@ mod tests {
|
|||||||
ctx.db.record_enclosure("has-file", "g1", &enc).await.unwrap();
|
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();
|
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();
|
let managed = ctx.db.managed_feeds().await.unwrap();
|
||||||
assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten");
|
assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten");
|
||||||
@@ -3076,7 +3082,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() {
|
async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() {
|
||||||
let mut cfg = config::Config::default();
|
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 ctx = Arc::new(test_ctx(cfg).await);
|
||||||
let (me, you) = (
|
let (me, you) = (
|
||||||
ctx.db.create_user("me", None, false).await.unwrap(),
|
ctx.db.create_user("me", None, false).await.unwrap(),
|
||||||
@@ -3097,7 +3103,7 @@ mod tests {
|
|||||||
let parent = ctx.cfg().feeds["opml"].clone();
|
let parent = ctx.cfg().feeds["opml"].clone();
|
||||||
sync_group(&ctx, "opml", &parent, &[("b".into(), "http://x/b.xml".into())]).await.unwrap();
|
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");
|
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.managed_feeds().await.unwrap().iter().any(|m| m.id == "a"));
|
||||||
assert!(ctx.db.subscription(me, "a").await.unwrap().is_some());
|
assert!(ctx.db.subscription(me, "a").await.unwrap().is_some());
|
||||||
assert!(ctx.db.subscription(you, "a").await.unwrap().is_none());
|
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"));
|
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.
|
// Once nobody reads it, b is mine alone, and yours goes with the OPML.
|
||||||
leave(&ctx, you, "opml").await.unwrap();
|
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(me, "b").await.unwrap().is_some());
|
||||||
assert!(ctx.db.subscription(you, "b").await.unwrap().is_none());
|
assert!(ctx.db.subscription(you, "b").await.unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user