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

@@ -0,0 +1,181 @@
use async_trait::async_trait;
use domain::{
errors::DomainError, ports::AccountPurger, value_objects::InstanceIdentity,
value_objects::UserId,
};
use sqlx::SqlitePool;
use crate::shared::map_sqlx_error;
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
/// Every table the purge empties for the account, with the column that holds
/// the user id. `reviews` is further restricted to the user's own (non-remote)
/// rows. A guard test compares this list with the schema, so a new
/// user-owned table cannot be forgotten.
pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
("reviews", "user_id"),
("watchlist_entries", "user_id"),
("goals", "user_id"),
("user_settings", "user_id"),
("user_profile_fields", "user_id"),
("webhook_tokens", "user_id"),
("watch_events", "user_id"),
("wrap_up_records", "user_id"),
("import_sessions", "user_id"),
("import_profiles", "user_id"),
("refresh_sessions", "user_id"),
("ap_followers", "local_user_id"),
("ap_following", "local_user_id"),
("blocked_actors", "local_user_id"),
];
/// Tables that reference a user but are kept until the tombstone cleanup:
/// the keypair is needed to sign queued federation deliveries.
pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"];
/// Tables with no user column whose rows hang off the user's content or actor
/// URL; the purge clears them by URL.
pub const URL_KEYED_USER_TABLES: &[&str] = &["ap_announces", "ap_followers", "ap_following"];
pub struct SqliteAccountPurger {
pool: SqlitePool,
instance: InstanceIdentity,
}
impl SqliteAccountPurger {
pub fn new(pool: SqlitePool, instance: InstanceIdentity) -> Self {
Self { pool, instance }
}
}
#[async_trait]
impl AccountPurger for SqliteAccountPurger {
async fn is_purged(&self, user: &UserId) -> Result<bool, DomainError> {
sqlx::query_scalar::<_, i64>(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ? AND purged_at IS NOT NULL)",
)
.bind(user.value().to_string())
.fetch_one(&self.pool)
.await
.map(|n| n != 0)
.map_err(map_sqlx_error)
}
async fn owned_image_keys(&self, user: &UserId) -> Result<Vec<String>, DomainError> {
let row: Option<(Option<String>, Option<String>)> = sqlx::query_as(
"SELECT avatar_path, banner_path FROM users WHERE id = ? AND deleted_at IS NOT NULL",
)
.bind(user.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row
.map(|(avatar, banner)| avatar.into_iter().chain(banner).collect())
.unwrap_or_default())
}
async fn purge(&self, user: &UserId) -> Result<(), DomainError> {
let id = user.value().to_string();
let actor_url = self.instance.actor_url_for(user);
let base = self.instance.base_url().to_string();
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
// Strip first: it claims the purge. Only a tombstoned, not yet purged
// account matches, so a live account (or a re-run) changes nothing.
let claimed = sqlx::query(
"UPDATE users SET email = 'deleted-' || id || '@deleted.invalid',
password_hash = '!purged', display_name = NULL, bio = NULL,
also_known_as = NULL, purged_at = ?
WHERE id = ? AND deleted_at IS NOT NULL AND purged_at IS NULL",
)
.bind(chrono::Utc::now().format(TIMESTAMP_FORMAT).to_string())
.bind(&id)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?
.rows_affected();
if claimed == 0 {
return Ok(());
}
// Announces of the user's content, matched before the reviews go.
sqlx::query(
"DELETE FROM ap_announces
WHERE object_url IN (
SELECT ?1 || '/reviews/' || id FROM reviews
WHERE user_id = ?2 AND remote_actor_url IS NULL)
OR substr(object_url, 1, length(?3)) = ?3
OR actor_url = ?4",
)
.bind(&base)
.bind(&id)
.bind(format!("{base}/users/{id}/"))
.bind(&actor_url)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
// Other accounts' follows of this one.
for table in ["ap_followers", "ap_following"] {
sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?"))
.bind(&actor_url)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
}
for (table, column) in PURGED_USER_TABLES {
let extra = if *table == "reviews" {
" AND remote_actor_url IS NULL"
} else {
""
};
sqlx::query(&format!("DELETE FROM {table} WHERE {column} = ?{extra}"))
.bind(&id)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
}
tx.commit().await.map_err(map_sqlx_error)
}
async fn forget_images(&self, user: &UserId) -> Result<(), DomainError> {
sqlx::query(
"UPDATE users SET avatar_path = NULL, banner_path = NULL
WHERE id = ? AND purged_at IS NOT NULL",
)
.bind(user.value().to_string())
.execute(&self.pool)
.await
.map(|_| ())
.map_err(map_sqlx_error)
}
async fn purge_expired_tombstones(
&self,
cutoff: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string();
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
for table in RETAINED_USER_TABLES {
sqlx::query(&format!(
"DELETE FROM {table} WHERE user_id IN (
SELECT id FROM users WHERE purged_at IS NOT NULL AND purged_at < ?)"
))
.bind(&cutoff)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
}
let removed =
sqlx::query("DELETE FROM users WHERE purged_at IS NOT NULL AND purged_at < ?")
.bind(&cutoff)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?
.rows_affected();
tx.commit().await.map_err(map_sqlx_error)?;
Ok(removed)
}
}

View File

@@ -1,3 +1,4 @@
mod account_purge;
mod ap_content;
mod diary;
mod goals;
@@ -23,6 +24,9 @@ mod watch_event;
mod watchlist;
mod wrapup;
pub use account_purge::{
PURGED_USER_TABLES, RETAINED_USER_TABLES, SqliteAccountPurger, URL_KEYED_USER_TABLES,
};
pub use ap_content::SqliteApContentQuery;
pub use diary::SqliteDiaryRepository;
pub use goals::SqliteGoalRepository;