Leave a removed account's feeds as unsubscribing does (#157)
Removing an account deleted the user, and the foreign keys took their subscriptions with them, but nothing did what unsubscribing does next: a feed nobody subscribes to any more leaves the catalogue (or stays, if listed, for the Directory). A feed only that person read stayed with no subscribers, and a feed with none downloads by its own settings, so it went on fetching each new episode, daily, for no one, until it had been quiet for a year. remove_feed's body is now leave(), and remove_user() leaves each of the person's feeds with it before deleting the account, from the page and from `ipx user rm` alike; a feed inside a group of theirs goes with the group. `ipx user rm` now tells a running daemon to reload, as add and rm do (#120), since it can change the catalogue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
73
src/main.rs
73
src/main.rs
@@ -316,7 +316,10 @@ async fn main() -> Result<()> {
|
||||
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||
// These write the catalogue from a process of their own, which a running daemon, holding its
|
||||
// own copy, would write over at its next change (#120).
|
||||
let changes_catalogue = matches!(cli.command, Command::Add { .. } | Command::Rm { .. } | Command::Import { .. });
|
||||
let changes_catalogue = matches!(
|
||||
cli.command,
|
||||
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
|
||||
);
|
||||
let (events, _) = broadcast::channel(1024);
|
||||
let ctx = Arc::new(Ctx {
|
||||
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
|
||||
@@ -507,7 +510,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
.db
|
||||
.user_by_name(&name).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
ctx.db.delete_user(user.id).await?;
|
||||
remove_user(ctx, user.id).await?;
|
||||
println!("removed {name}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1332,6 +1335,50 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's
|
||||
/// lists are left alone.
|
||||
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
|
||||
ctx.db.unsubscribe(user_id, id).await?;
|
||||
for child in subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id)) {
|
||||
ctx.db.unsubscribe(user_id, &child.id).await?;
|
||||
}
|
||||
if ctx.db.subscriber_counts().await?.contains_key(id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
||||
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
||||
// admin listed stays in the Directory, for the next person.
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
if cfg.feeds.get(id).is_some_and(|f| f.listed) {
|
||||
return Ok(());
|
||||
}
|
||||
if cfg.feeds.remove(id).is_none() {
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys
|
||||
/// took the subscriptions with the account, but not the feed: one only they read stayed in the
|
||||
/// catalogue with nobody subscribed, downloading every new episode for no one (#157).
|
||||
pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> {
|
||||
let catalogue = subscriptions(ctx).await?;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
for id in &mine {
|
||||
// A feed in a group of theirs goes with the group.
|
||||
let in_theirs = catalogue.iter().any(|s| &s.id == id && s.cfg.group.as_ref().is_some_and(|g| mine.contains(g)));
|
||||
if !in_theirs {
|
||||
leave(ctx, user_id, id).await?;
|
||||
}
|
||||
}
|
||||
ctx.db.delete_user(user_id).await
|
||||
}
|
||||
|
||||
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
|
||||
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
|
||||
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
|
||||
@@ -2374,6 +2421,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_removed_account_takes_the_feeds_only_it_read_with_it() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let mut cfg = ctx.edit_cfg().await;
|
||||
for id in ["only-theirs", "shared", "listed"] {
|
||||
cfg.feeds.insert(id.into(), config::Feed { listed: id == "listed", ..feed() });
|
||||
}
|
||||
ctx.store_cfg(cfg).await.unwrap();
|
||||
let gone = ctx.db.create_user("gone", None, false).await.unwrap();
|
||||
let stays = ctx.db.create_user("stays", None, true).await.unwrap();
|
||||
for id in ["only-theirs", "shared", "listed"] {
|
||||
ctx.db.subscribe(gone, id).await.unwrap();
|
||||
}
|
||||
ctx.db.subscribe(stays, "shared").await.unwrap();
|
||||
|
||||
remove_user(&ctx, gone).await.unwrap();
|
||||
assert!(ctx.db.user_by_id(gone).await.unwrap().is_none());
|
||||
// Nobody reads it now: out of the catalogue, not downloading for no one (#157).
|
||||
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["listed", "shared"]);
|
||||
assert!(ctx.db.subscription(stays, "shared").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_changes_to_the_catalogue_at_once_both_keep() {
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
|
||||
31
src/web.rs
31
src/web.rs
@@ -558,7 +558,7 @@ async fn remove_user(
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
state.ctx.db.delete_user(id).await?;
|
||||
crate::remove_user(&state.ctx, id).await?;
|
||||
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1662,34 +1662,7 @@ async fn remove_feed(
|
||||
Path(id): Path<String>,
|
||||
user: crate::db::User,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
||||
// else's alone.
|
||||
state.ctx.db.unsubscribe(user.id, &id).await?;
|
||||
for child in crate::subscriptions(&state.ctx).await?
|
||||
.iter()
|
||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
state.ctx.db.unsubscribe(user.id, &child.id).await?;
|
||||
}
|
||||
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
||||
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
||||
// admin listed stays in the Directory, for the next person.
|
||||
let mut cfg = state.ctx.edit_cfg().await;
|
||||
if cfg.feeds.get(&id).is_some_and(|f| f.listed) {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
if cfg.feeds.remove(&id).is_none() {
|
||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||
// read unless you unsubscribe from the OPML itself.
|
||||
state.ctx.db.drop_managed(&id).await?;
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
crate::retire_group(&state.ctx, &id).await?;
|
||||
crate::leave(&state.ctx, user.id, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user