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

@@ -3,6 +3,7 @@ mod import_cleanup;
mod movie_dedup;
mod processed_events_cleanup;
mod refresh_session_cleanup;
mod tombstone_cleanup;
mod watch_event_cleanup;
mod wrapup;
@@ -11,5 +12,6 @@ pub use import_cleanup::ImportSessionCleanupJob;
pub use movie_dedup::MovieDeduplicationJob;
pub use processed_events_cleanup::{PROCESSED_EVENTS_RETENTION_DAYS, ProcessedEventsCleanupJob};
pub use refresh_session_cleanup::RefreshSessionCleanupJob;
pub use tombstone_cleanup::{TOMBSTONE_RETENTION_DAYS, TombstoneCleanupJob};
pub use watch_event_cleanup::WatchEventCleanupJob;
pub use wrapup::{WrapUpAutoGenerateJob, WrapUpCleanupJob};

View File

@@ -0,0 +1,40 @@
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{AccountPurger, PeriodicJob},
};
/// How long a purged account's stripped user row and signing keypair stay.
/// Federation deliveries of the account's `Delete` are signed from them, and
/// their retries are long over by then.
pub const TOMBSTONE_RETENTION_DAYS: i64 = 7;
pub struct TombstoneCleanupJob {
purger: Arc<dyn AccountPurger>,
}
impl TombstoneCleanupJob {
pub fn new(purger: Arc<dyn AccountPurger>) -> Self {
Self { purger }
}
}
#[async_trait]
impl PeriodicJob for TombstoneCleanupJob {
fn interval(&self) -> Duration {
Duration::from_secs(24 * 3600)
}
async fn run(&self) -> Result<(), DomainError> {
let cutoff =
chrono::Utc::now().naive_utc() - chrono::Duration::days(TOMBSTONE_RETENTION_DAYS);
let n = self.purger.purge_expired_tombstones(cutoff).await?;
if n > 0 {
tracing::info!("tombstone cleanup: removed {n} purged accounts");
}
Ok(())
}
}

View File

@@ -11,6 +11,7 @@ pub mod get_page_viewer;
pub mod get_profile_settings;
pub mod get_settings;
pub mod get_users;
pub mod purge_account;
pub mod queries;
pub mod request_account_deletion;
pub mod resolve_username_to_id;

View File

@@ -0,0 +1,77 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
AccountPurger, EventHandler, FederationDeletionNotifier, ObjectStorage, UserRepository,
},
};
/// Purges a deleted account once its `UserDeleted` event arrives.
///
/// Order matters. Federation is told first (deliveries are only enqueued, and
/// signed later from the tombstone the purge leaves behind). A notifier
/// failure never blocks the purge: unreachable servers must not keep a
/// person's data. Then the database rows go in one transaction, and the
/// image objects after the commit. Image references are dropped only once the
/// objects are gone, so a crash in between is recovered by the redelivery.
///
/// Idempotent: every step is a no-op on a re-run, and the notification is
/// skipped once the account is purged.
pub struct AccountPurgeHandler {
user: Arc<dyn UserRepository>,
notifier: Arc<dyn FederationDeletionNotifier>,
purger: Arc<dyn AccountPurger>,
object_storage: Arc<dyn ObjectStorage>,
}
impl AccountPurgeHandler {
pub fn new(
user: Arc<dyn UserRepository>,
notifier: Arc<dyn FederationDeletionNotifier>,
purger: Arc<dyn AccountPurger>,
object_storage: Arc<dyn ObjectStorage>,
) -> Self {
Self {
user,
notifier,
purger,
object_storage,
}
}
}
#[async_trait]
impl EventHandler for AccountPurgeHandler {
fn name(&self) -> &'static str {
"account_purge"
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let DomainEvent::UserDeleted { user_id } = event else {
return Ok(());
};
// Gone already (removed by the tombstone cleanup): nothing left to do.
if self.user.find_tombstoned_by_id(user_id).await?.is_none() {
return Ok(());
}
if !self.purger.is_purged(user_id).await?
&& let Err(e) = self.notifier.notify_deleted(user_id).await
{
tracing::warn!(user = %user_id.value(), "federation delete notice failed, purging anyway: {e}");
}
let keys = self.purger.owned_image_keys(user_id).await?;
self.purger.purge(user_id).await?;
for key in &keys {
self.object_storage.delete(key).await?;
}
if !keys.is_empty() {
self.purger.forget_images(user_id).await?;
}
Ok(())
}
}

View File

@@ -1,2 +1,4 @@
#[path = "jobs/processed_events_cleanup_test.rs"]
mod processed_events_cleanup_test;
#[path = "jobs/tombstone_cleanup_test.rs"]
mod tombstone_cleanup_test;

View File

@@ -0,0 +1,59 @@
use std::sync::{Arc, Mutex};
use application::jobs::{TOMBSTONE_RETENTION_DAYS, TombstoneCleanupJob};
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{AccountPurger, PeriodicJob},
value_objects::UserId,
};
#[derive(Default)]
struct Purger {
cutoffs: Mutex<Vec<chrono::NaiveDateTime>>,
}
#[async_trait]
impl AccountPurger for Purger {
async fn is_purged(&self, _: &UserId) -> Result<bool, DomainError> {
unimplemented!()
}
async fn owned_image_keys(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
unimplemented!()
}
async fn purge(&self, _: &UserId) -> Result<(), DomainError> {
unimplemented!()
}
async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> {
unimplemented!()
}
async fn purge_expired_tombstones(
&self,
cutoff: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
self.cutoffs.lock().unwrap().push(cutoff);
Ok(0)
}
}
#[tokio::test]
async fn removes_tombstones_purged_before_the_retention_window() {
assert_eq!(TOMBSTONE_RETENTION_DAYS, 7);
let purger = Arc::new(Purger::default());
TombstoneCleanupJob::new(purger.clone())
.run()
.await
.unwrap();
let cutoffs = purger.cutoffs.lock().unwrap();
let expected = chrono::Utc::now().naive_utc() - chrono::Duration::days(7);
assert_eq!(cutoffs.len(), 1);
assert!((cutoffs[0] - expected).num_seconds().abs() < 5);
}
#[test]
fn runs_daily() {
let job = TombstoneCleanupJob::new(Arc::new(Purger::default()));
assert_eq!(job.interval(), std::time::Duration::from_secs(86_400));
}

View File

@@ -20,6 +20,8 @@ mod get_profile_settings_test;
mod get_settings_test;
#[path = "users/get_users_test.rs"]
mod get_users_test;
#[path = "users/purge_account_test.rs"]
mod purge_account_test;
#[path = "users/request_account_deletion_test.rs"]
mod request_account_deletion_test;
#[path = "users/resolve_username_to_id_test.rs"]

View File

@@ -0,0 +1,253 @@
use std::sync::{Arc, Mutex};
use application::users::purge_account::AccountPurgeHandler;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope, EventId},
ports::{
AccountPurger, EventConsumer, EventHandler, FederationDeletionNotifier, ObjectStorage,
UserRepository,
},
testing::{InMemoryStore, NoopAckHandle, test_user},
value_objects::UserId,
};
use futures::stream::{self, BoxStream};
type Log = Arc<Mutex<Vec<String>>>;
struct Notifier {
log: Log,
fail: bool,
}
#[async_trait]
impl FederationDeletionNotifier for Notifier {
async fn notify_deleted(&self, _: &UserId) -> Result<(), DomainError> {
self.log.lock().unwrap().push("notify".into());
if self.fail {
Err(DomainError::InfrastructureError("peer unreachable".into()))
} else {
Ok(())
}
}
}
struct Purger {
log: Log,
purged: Mutex<bool>,
keys: Vec<String>,
}
#[async_trait]
impl AccountPurger for Purger {
async fn is_purged(&self, _: &UserId) -> Result<bool, DomainError> {
Ok(*self.purged.lock().unwrap())
}
async fn owned_image_keys(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
Ok(self.keys.clone())
}
async fn purge(&self, _: &UserId) -> Result<(), DomainError> {
self.log.lock().unwrap().push("purge".into());
*self.purged.lock().unwrap() = true;
Ok(())
}
async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> {
self.log.lock().unwrap().push("forget".into());
Ok(())
}
async fn purge_expired_tombstones(&self, _: chrono::NaiveDateTime) -> Result<u64, DomainError> {
Ok(0)
}
}
struct Storage {
log: Log,
}
#[async_trait]
impl ObjectStorage for Storage {
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> {
unimplemented!()
}
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
unimplemented!()
}
async fn get_stream(
&self,
_: &str,
) -> Result<BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError> {
unimplemented!()
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
self.log.lock().unwrap().push(format!("delete {key}"));
Ok(())
}
async fn exists(&self, _: &str) -> Result<bool, DomainError> {
unimplemented!()
}
}
struct Fixture {
handler: Arc<AccountPurgeHandler>,
log: Log,
store: Arc<InMemoryStore>,
user: UserId,
}
async fn fixture(notify_fails: bool, tombstoned: bool) -> Fixture {
let store = InMemoryStore::new();
let alice = test_user("alice");
store.save(&alice).await.unwrap();
if tombstoned {
store.mark_deleted(alice.id(), &[]).await.unwrap();
}
let log: Log = Arc::default();
let handler = Arc::new(AccountPurgeHandler::new(
store.clone(),
Arc::new(Notifier {
log: log.clone(),
fail: notify_fails,
}),
Arc::new(Purger {
log: log.clone(),
purged: Mutex::new(false),
keys: vec!["avatars/a.webp".into(), "banners/a.webp".into()],
}),
Arc::new(Storage { log: log.clone() }),
));
Fixture {
handler,
log,
store,
user: alice.id().clone(),
}
}
fn deleted(user: &UserId) -> DomainEvent {
DomainEvent::UserDeleted {
user_id: user.clone(),
}
}
fn entries(log: &Log) -> Vec<String> {
log.lock().unwrap().clone()
}
#[test]
fn the_handler_name_is_stable() {
// The name keys the processed-event ledger; renaming re-runs events.
let f = tokio::runtime::Runtime::new()
.unwrap()
.block_on(fixture(false, true));
assert_eq!(f.handler.name(), "account_purge");
}
#[tokio::test]
async fn notifies_first_then_purges_then_removes_images_then_forgets_them() {
let f = fixture(false, true).await;
f.handler.handle(&deleted(&f.user)).await.unwrap();
assert_eq!(
entries(&f.log),
[
"notify",
"purge",
"delete avatars/a.webp",
"delete banners/a.webp",
"forget"
]
);
}
#[tokio::test]
async fn purge_continues_when_federation_notify_fails() {
let f = fixture(true, true).await;
f.handler.handle(&deleted(&f.user)).await.unwrap();
let log = entries(&f.log);
assert_eq!(log[0], "notify");
assert!(log.contains(&"purge".to_string()), "{log:?}");
assert!(log.contains(&"forget".to_string()), "{log:?}");
}
#[tokio::test]
async fn a_rerun_does_not_notify_again() {
let f = fixture(false, true).await;
f.handler.handle(&deleted(&f.user)).await.unwrap();
f.handler.handle(&deleted(&f.user)).await.unwrap();
let notices = entries(&f.log).iter().filter(|e| *e == "notify").count();
assert_eq!(notices, 1);
}
#[tokio::test]
async fn does_nothing_when_the_account_is_not_tombstoned() {
let live = fixture(false, false).await;
live.handler.handle(&deleted(&live.user)).await.unwrap();
assert!(entries(&live.log).is_empty(), "a live user is never purged");
let unknown = fixture(false, true).await;
unknown
.handler
.handle(&deleted(&UserId::generate()))
.await
.unwrap();
assert!(entries(&unknown.log).is_empty());
}
#[tokio::test]
async fn ignores_other_events() {
let f = fixture(false, true).await;
f.handler
.handle(&DomainEvent::UserUpdated {
user_id: f.user.clone(),
})
.await
.unwrap();
assert!(entries(&f.log).is_empty());
}
struct TwiceConsumer {
id: EventId,
event: DomainEvent,
}
impl EventConsumer for TwiceConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let deliveries: Vec<Result<EventEnvelope, DomainError>> = (0..2)
.map(|_| {
Ok(EventEnvelope::new(
Some(self.id),
self.event.clone(),
Box::new(NoopAckHandle),
))
})
.collect();
Box::pin(stream::iter(deliveries))
}
}
#[tokio::test]
async fn a_redelivered_event_is_skipped_by_the_ledger() {
let f = fixture(false, true).await;
let consumer = TwiceConsumer {
id: EventId::new(),
event: deleted(&f.user),
};
application::worker::WorkerService::new(
Arc::new(consumer),
vec![f.handler.clone() as Arc<dyn EventHandler>],
f.store.clone(),
)
.unwrap()
.run(tokio::sync::watch::channel(false).1)
.await;
let purges = entries(&f.log).iter().filter(|e| *e == "purge").count();
assert_eq!(purges, 1);
}