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,38 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::FederationDeletionNotifier,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::ActivityPubService;
use crate::urls::actor_url;
/// Broadcasts the actor `Delete` to the user's accepted followers.
///
/// k-ap only enqueues the per-inbox deliveries; they are signed later from the
/// tombstoned user row and keypair, which the purge keeps until the tombstone
/// cleanup (see `AccountPurger`).
pub struct ApDeletionNotifier {
service: Arc<ActivityPubService>,
instance: InstanceIdentity,
}
impl ApDeletionNotifier {
pub fn new(service: Arc<ActivityPubService>, instance: InstanceIdentity) -> Self {
Self { service, instance }
}
}
#[async_trait]
impl FederationDeletionNotifier for ApDeletionNotifier {
async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError> {
let ap_id = actor_url(&self.instance, user.value());
self.service
.broadcast_delete_to_followers(user.value(), ap_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}

View File

@@ -143,13 +143,6 @@ impl EventHandler for ActivityPubEventHandler {
.on_goal_deleted(user_id, *year)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::UserDeleted { user_id } => {
let ap_id = actor_url(&self.instance, user_id.value());
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
DomainEvent::UserAccountMoved {
user_id,
new_actor_url,

View File

@@ -4,18 +4,20 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{BlockedDomainInfo, FollowedActorInfo},
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort},
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort, UserRepository},
value_objects::UserId,
};
use k_ap::ActivityPubService;
use uuid::Uuid;
pub struct ApServiceAdapter {
service: Arc<ActivityPubService>,
users: Arc<dyn UserRepository>,
}
impl ApServiceAdapter {
pub fn new(service: Arc<ActivityPubService>) -> Self {
Self { service }
pub fn new(service: Arc<ActivityPubService>, users: Arc<dyn UserRepository>) -> Self {
Self { service, users }
}
}
@@ -26,6 +28,13 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait]
impl ApDocumentPort for ApServiceAdapter {
async fn actor_is_gone(&self, user_id: Uuid) -> Result<bool, DomainError> {
Ok(self
.users
.find_tombstoned_by_id(&UserId::from_uuid(user_id))
.await?
.is_some())
}
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError> {
self.service.actor_json(user_id).await.map_err(ap_err)
}

View File

@@ -1,4 +1,5 @@
pub mod composite_handler;
pub mod deletion_notifier;
pub mod event_handler;
pub mod federation_event_bridge;
pub mod federation_ports;
@@ -7,6 +8,7 @@ pub mod objects;
pub mod remote_review_repository;
pub mod review_handler;
pub mod social_adapter;
pub mod tombstone_gate;
pub(crate) mod urls;
pub mod user_adapter;
pub mod watchlist_handler;
@@ -20,6 +22,7 @@ pub use k_ap::{
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
};
pub use deletion_notifier::ApDeletionNotifier;
pub use event_handler::ActivityPubEventHandler;
pub use federation_ports::ApServiceAdapter;
pub use remote_review_repository::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate};
@@ -46,6 +49,7 @@ pub struct ActivityPubWire {
pub service: std::sync::Arc<ActivityPubService>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
pub deletion_notifier: std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>,
}
pub struct ActivityPubDeps {
@@ -139,7 +143,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
.actor_repo(actor_repo)
.blocklist_repo(blocklist_repo)
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
user_repo,
std::sync::Arc::clone(&user_repo),
instance.clone(),
)))
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
@@ -157,7 +161,16 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
.await?,
);
let router = concrete.router();
let router = concrete
.router()
.layer(axum::middleware::from_fn_with_state(
std::sync::Arc::clone(&user_repo),
tombstone_gate::tombstone_gate,
));
let deletion_notifier = std::sync::Arc::new(ApDeletionNotifier::new(
std::sync::Arc::clone(&concrete),
instance.clone(),
)) as std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>;
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
local_ap_content,
@@ -171,6 +184,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new(
std::sync::Arc::clone(&concrete),
std::sync::Arc::clone(&user_repo),
));
Ok(ActivityPubWire {
@@ -182,5 +196,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
service: concrete,
router,
event_handler,
deletion_notifier,
})
}

View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use domain::{ports::UserRepository, value_objects::UserId};
/// Answers `410 Gone` for the per-user ActivityPub endpoints of a deleted
/// account. k-ap resolves tombstones so it can sign the account's `Delete`
/// deliveries; this keeps that from making the actor look alive.
pub async fn tombstone_gate(
State(users): State<Arc<dyn UserRepository>>,
request: Request,
next: Next,
) -> Response {
if let Some(id) = gated_user(request.uri().path())
&& matches!(
users.find_tombstoned_by_id(&UserId::from_uuid(id)).await,
Ok(Some(_))
)
{
return StatusCode::GONE.into_response();
}
next.run(request).await
}
/// The user id in `/users/{id}/outbox|featured|inbox`.
pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
let rest = path.strip_prefix("/users/")?;
let (id, endpoint) = rest.split_once('/')?;
matches!(endpoint, "outbox" | "featured" | "inbox").then_some(())?;
uuid::Uuid::parse_str(id).ok()
}

View File

@@ -64,9 +64,16 @@ impl DomainUserRepoAdapter {
impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id);
// A deleted account still resolves here, so k-ap can sign the queued
// `Delete` deliveries until the tombstone cleanup. Username lookups
// below stay live-only, and the AP endpoints answer 410 for it
// (`tombstone_gate`, `actor_document_response`).
let user = match self.repo.find_by_id(&user_id).await? {
Some(u) => u,
None => return Ok(None),
None => match self.repo.find_tombstoned_by_id(&user_id).await? {
Some(u) => u,
None => return Ok(None),
},
};
Ok(Some(self.build_user(&user)))
}

View File

@@ -0,0 +1,59 @@
use domain::{
ports::UserRepository,
testing::{InMemoryStore, test_user},
value_objects::InstanceIdentity,
};
use k_ap::ApUserRepository;
use activitypub::DomainUserRepoAdapter;
async fn adapter_with_deleted_alice() -> (DomainUserRepoAdapter, uuid::Uuid) {
let store = InMemoryStore::new();
let alice = test_user("alice");
store.save(&alice).await.unwrap();
store.mark_deleted(alice.id(), &[]).await.unwrap();
let adapter = DomainUserRepoAdapter::new(store, InstanceIdentity::new("https://md.example"));
(adapter, alice.id().value())
}
#[tokio::test]
async fn tombstoned_actor_can_still_sign() {
let (adapter, id) = adapter_with_deleted_alice().await;
let user = adapter.find_by_id(id).await.unwrap();
assert_eq!(user.expect("k-ap resolves the tombstone").username, "alice");
}
#[tokio::test]
async fn a_tombstone_is_not_found_by_username() {
let (adapter, _) = adapter_with_deleted_alice().await;
assert!(adapter.find_by_username("alice").await.unwrap().is_none());
}
#[tokio::test]
async fn an_unknown_user_is_none() {
let (adapter, _) = adapter_with_deleted_alice().await;
assert!(
adapter
.find_by_id(uuid::Uuid::new_v4())
.await
.unwrap()
.is_none()
);
}
#[test]
fn only_the_per_user_endpoints_are_gated() {
use activitypub::tombstone_gate::gated_user;
let id = uuid::Uuid::new_v4();
for endpoint in ["outbox", "featured", "inbox"] {
assert_eq!(gated_user(&format!("/users/{id}/{endpoint}")), Some(id));
}
assert_eq!(gated_user(&format!("/users/{id}")), None);
assert_eq!(gated_user(&format!("/users/{id}/followers")), None);
assert_eq!(gated_user("/inbox"), None);
assert_eq!(gated_user("/users/not-a-uuid/outbox"), None);
}