feat: account purge handler, tombstone cleanup, AP tombstone signing + 410
This commit is contained in:
38
crates/adapters/activitypub/src/deletion_notifier.rs
Normal file
38
crates/adapters/activitypub/src/deletion_notifier.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
36
crates/adapters/activitypub/src/tombstone_gate.rs
Normal file
36
crates/adapters/activitypub/src/tombstone_gate.rs
Normal 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()
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
59
crates/adapters/activitypub/tests/user_adapter.rs
Normal file
59
crates/adapters/activitypub/tests/user_adapter.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user