feat: account purge handler, tombstone cleanup, AP tombstone signing + 410

This commit is contained in:
2026-10-26 02:30:09 +01:00
parent 9a03de8956
commit 51a70b5260
32 changed files with 1708 additions and 27 deletions

View File

@@ -40,6 +40,7 @@ pub struct DatabaseOutput {
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub processed_events: Arc<dyn domain::ports::ProcessedEventLedger>,
pub deduplicator: Arc<dyn MovieDeduplicator>,
pub account_purger: Arc<dyn domain::ports::AccountPurger>,
pub image_ref_command: Arc<dyn ImageRefCommand>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub follow_command: Arc<dyn domain::ports::FollowCommand>,
@@ -117,6 +118,10 @@ fn assemble(
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) as _,
processed_events: Arc::new(sqlite::SqliteProcessedEventLedger::new(w.pool.clone())) as _,
deduplicator: w.deduplicator,
account_purger: Arc::new(sqlite::SqliteAccountPurger::new(
w.pool.clone(),
instance.clone(),
)) as _,
image_ref_command,
image_ref_query,
follow_command: Arc::clone(&social) as _,

View File

@@ -123,15 +123,19 @@ pub async fn wire_server_social(
})
}
/// The worker's federation event handlers: the ActivityPub delivery handler and
/// the follow-accepted outbox backfill.
/// The worker's federation event handlers (the ActivityPub delivery handler and
/// the follow-accepted outbox backfill) and the notifier that tells followers
/// an account was deleted.
#[cfg(feature = "federation")]
pub async fn wire_worker_handlers(
db: &DatabaseOutput,
instance: &InstanceIdentity,
config: &config::AppConfig,
event_publisher: &Arc<dyn EventPublisher>,
) -> anyhow::Result<Vec<Arc<dyn domain::ports::EventHandler>>> {
) -> anyhow::Result<(
Vec<Arc<dyn domain::ports::EventHandler>>,
Arc<dyn domain::ports::FederationDeletionNotifier>,
)> {
let fed_repos = sqlite_federation::wire(db.db_pool.clone(), instance.clone());
let ap_wire = activitypub::wire(activitypub::ActivityPubDeps {
@@ -160,10 +164,13 @@ pub async fn wire_worker_handlers(
.await?;
tracing::info!("federation event handler registered");
Ok(vec![
ap_wire.event_handler,
Arc::new(crate::follow_backfill_handler::FollowBackfillHandler {
backfill: ap_wire.backfill,
}),
])
Ok((
vec![
ap_wire.event_handler,
Arc::new(crate::follow_backfill_handler::FollowBackfillHandler {
backfill: ap_wire.backfill,
}),
],
ap_wire.deletion_notifier,
))
}

View File

@@ -133,9 +133,12 @@ async fn assemble(
let image_ref_query = Arc::clone(&db.image_ref_query);
#[cfg(feature = "federation")]
let federation_handlers =
let (federation_handlers, deletion_notifier) =
crate::federation::wire_worker_handlers(&db, &instance, config, &event_publisher_arc)
.await?;
#[cfg(not(feature = "federation"))]
let deletion_notifier: Arc<dyn domain::ports::FederationDeletionNotifier> =
Arc::new(domain::ports::noop::NoopFederationDeletionNotifier);
let movie_command = db.movie_command;
let movie_query = db.movie_query;
@@ -148,6 +151,7 @@ async fn assemble(
let wrapup_repo = db.wrapup_repo;
let refresh_session = db.refresh_session;
let processed_events = db.processed_events;
let account_purger = db.account_purger;
let event_publisher = event_publisher_arc;
let metadata = metadata_client;
@@ -225,6 +229,9 @@ async fn assemble(
Arc::new(application::jobs::ProcessedEventsCleanupJob::new(
Arc::clone(&processed_events),
)),
Arc::new(application::jobs::TombstoneCleanupJob::new(Arc::clone(
&account_purger,
))),
];
if let Some(job) = enrichment_job {
periodic_jobs.push(job);
@@ -282,7 +289,15 @@ async fn assemble(
let reindex_handler = Arc::new(SearchReindexHandler::new(worker_deps.reindex_search))
as Arc<dyn EventHandler>;
let account_purge = Arc::new(application::users::purge_account::AccountPurgeHandler::new(
Arc::clone(&user),
deletion_notifier,
Arc::clone(&account_purger),
Arc::clone(&object_storage),
)) as Arc<dyn EventHandler>;
let mut h = vec![
account_purge,
poster,
cleanup,
search_cleanup,

View File

@@ -0,0 +1,130 @@
//! End to end on the real wiring: a `UserDeleted` event in the outbox makes
//! the worker tell followers, purge the account, and leave the tombstone that
//! the queued deliveries are signed from.
mod support;
use std::time::Duration;
use domain::{events::DomainEvent, ports::UserRepository, value_objects::UserId};
async fn wait_for(pool: &sqlx::SqlitePool, sql: &str) -> bool {
for _ in 0..100 {
let n: i64 = sqlx::query_scalar(sql).fetch_one(pool).await.unwrap();
if n > 0 {
return true;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
#[tokio::test]
async fn the_worker_purges_a_deleted_account_and_frees_its_email() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
let users = sqlite::SqliteUserRepository::new(pool.clone());
users
.mark_deleted(
&id,
&[DomainEvent::UserDeleted {
user_id: id.clone(),
}],
)
.await
.unwrap();
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
.await
.unwrap();
let (stop, stopped) = tokio::sync::watch::channel(false);
let running = tokio::spawn(worker.run(stopped));
let purged = wait_for(
&pool,
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
)
.await;
stop.send(true).unwrap();
running.await.unwrap().unwrap();
assert!(purged, "the worker should purge the tombstoned account");
let email: String = sqlx::query_scalar("SELECT email FROM users WHERE username = 'alice'")
.fetch_one(&pool)
.await
.unwrap();
assert_ne!(email, "alice@example.com");
let again = support::register(&router, "alice2", "alice@example.com").await;
assert!(!again.user_id.is_empty(), "the email can register again");
}
/// With a follower, the Delete is enqueued for delivery even though the
/// account is already tombstoned, and the keypair outlives the purge.
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_are_told_before_the_purge_and_the_keypair_survives_it() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
for sql in [
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at)
VALUES ('http://127.0.0.1:1/users/ann', 'ann@127.0.0.1', 'http://127.0.0.1:1/inbox', 'now')",
] {
sqlx::query(sql).execute(&pool).await.unwrap();
}
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
VALUES (?, 'http://127.0.0.1:1/users/ann', 'accepted', 'now')",
)
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
let users = sqlite::SqliteUserRepository::new(pool.clone());
users
.mark_deleted(
&id,
&[DomainEvent::UserDeleted {
user_id: id.clone(),
}],
)
.await
.unwrap();
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
.await
.unwrap();
let (stop, stopped) = tokio::sync::watch::channel(false);
let running = tokio::spawn(worker.run(stopped));
let purged = wait_for(
&pool,
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
)
.await;
// Deliveries are enqueued from a spawned task, so wait for them too.
let enqueued = wait_for(
&pool,
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
)
.await;
stop.send(true).unwrap();
running.await.unwrap().unwrap();
assert!(purged);
assert!(enqueued, "the Delete should be enqueued for the follower");
let deliveries: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(deliveries, 1, "the Delete was enqueued for the follower");
let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_local_actors WHERE user_id = ?")
.bind(&alice.user_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(keys, 1, "the keypair stays until the tombstone cleanup");
}

View File

@@ -129,6 +129,7 @@ async fn handler_names_are_unique() {
// Names key the ledger: a rename re-runs events inside the retention
// window, so changing this list must be a deliberate edit.
let mut expected = vec![
"account_purge",
"image_cleanup",
"movie_discovery_indexer",
"movie_enrichment",
@@ -253,3 +254,49 @@ async fn a_deleted_users_token_is_rejected() {
assert_eq!(headers.get("location"), anon_headers.get("location"));
assert_eq!(headers.get("location").unwrap(), "/login");
}
/// A deleted account stays resolvable inside k-ap (to sign its `Delete`), but
/// every ActivityPub document for it answers 410, while the classic profile
/// page stays 404.
#[cfg(feature = "federation")]
#[tokio::test]
async fn actor_document_for_a_tombstone_is_gone() {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let ap_get = |path: String| {
axum::http::Request::builder()
.uri(path)
.header("accept", "application/activity+json")
.body(axum::body::Body::empty())
.unwrap()
};
let paths = |id: &str| {
[
format!("/users/{id}"),
format!("/users/{id}/followers"),
format!("/users/{id}/following"),
format!("/users/{id}/outbox"),
format!("/users/{id}/featured"),
]
};
let (status, _, body) =
support::send(&router, ap_get(format!("/users/{}", alice.user_id))).await;
assert_eq!(status, 200, "live actor: {body}");
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
.bind(&alice.user_id)
.execute(&pool)
.await
.unwrap();
for path in paths(&alice.user_id) {
let (status, _, body) = support::send(&router, ap_get(path.clone())).await;
assert_eq!(status, 410, "{path}: {body}");
}
let (status, _, _) = support::send(
&router,
support::get(&format!("/users/{}", alice.user_id), None),
)
.await;
assert_eq!(status, 404, "the classic profile page stays 404");
}