From e04b08c202471690457221b68811d167ad3c4618 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 04:58:38 +0200 Subject: [PATCH] feat(application): federate local likes + locality guard prevents remote boost re-broadcast --- .../src/services/federation_event.rs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/crates/application/src/services/federation_event.rs b/crates/application/src/services/federation_event.rs index 8b57fb1..7d4f9d1 100644 --- a/crates/application/src/services/federation_event.rs +++ b/crates/application/src/services/federation_event.rs @@ -93,6 +93,12 @@ impl FederationEventService { user_id, thought_id, } => { + // Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast. + let booster = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = booster; let thought = match self.thoughts.find_by_id(thought_id).await? { Some(t) => t, None => return Ok(()), @@ -220,6 +226,56 @@ impl FederationEventService { Ok(()) } + DomainEvent::LikeAdded { + like_id: _, + user_id, + thought_id, + } => { + // Only federate: local liker + remote thought (has ap_id) + author has inbox. + let liker = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = liker; + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) if t.ap_id.is_some() => t, + _ => return Ok(()), + }; + let author = match self.users.find_by_id(&thought.user_id).await? { + Some(u) if u.inbox_url.is_some() => u, + _ => return Ok(()), + }; + let object_ap_id = thought.ap_id.unwrap(); + let inbox_url = author.inbox_url.unwrap(); + self.ap + .broadcast_like(user_id, &object_ap_id, &inbox_url) + .await + } + + DomainEvent::LikeRemoved { + user_id, + thought_id, + } => { + let liker = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = liker; + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) if t.ap_id.is_some() => t, + _ => return Ok(()), + }; + let author = match self.users.find_by_id(&thought.user_id).await? { + Some(u) if u.inbox_url.is_some() => u, + _ => return Ok(()), + }; + let object_ap_id = thought.ap_id.unwrap(); + let inbox_url = author.inbox_url.unwrap(); + self.ap + .broadcast_undo_like(user_id, &object_ap_id, &inbox_url) + .await + } + _ => Ok(()), } } @@ -436,6 +492,7 @@ mod tests { let store = TestStore::default(); let alice = alice(); let thought = local_thought(alice.id.clone()); // ap_id = None + store.users.lock().unwrap().push(alice.clone()); store.thoughts.lock().unwrap().push(thought.clone()); let spy = Arc::new(SpyPort::default()); @@ -463,6 +520,7 @@ mod tests { let mut thought = local_thought(alice.id.clone()); thought.local = false; thought.ap_id = Some("https://mastodon.social/users/bob/statuses/123".into()); + store.users.lock().unwrap().push(alice.clone()); store.thoughts.lock().unwrap().push(thought.clone()); let spy = Arc::new(SpyPort::default()); @@ -693,4 +751,103 @@ mod tests { .await .unwrap(); } + + #[tokio::test] + async fn like_added_local_user_remote_thought_broadcasts_like() { + let store = TestStore::default(); + + let mut author = User::new_local( + UserId::new(), + Username::new("remote_author").unwrap(), + Email::new("r@remote.example").unwrap(), + PasswordHash("h".into()), + ); + author.local = false; + author.inbox_url = Some("https://mastodon.social/users/author/inbox".into()); + + let mut thought = local_thought(author.id.clone()); + thought.ap_id = Some("https://mastodon.social/posts/123".into()); + + let liker = alice(); + + store.users.lock().unwrap().push(author.clone()); + store.users.lock().unwrap().push(liker.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: liker.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert_eq!(spy.liked.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn like_added_remote_user_skips_broadcast() { + let store = TestStore::default(); + + let author = alice(); + let thought = local_thought(author.id.clone()); // local thought — no ap_id + + let mut remote_liker = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@remote").unwrap(), + PasswordHash("h".into()), + ); + remote_liker.local = false; + + store.users.lock().unwrap().push(author); + store.users.lock().unwrap().push(remote_liker.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: remote_liker.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert!(spy.liked.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn boost_added_remote_user_skips_broadcast() { + let store = TestStore::default(); + + let author = alice(); + let thought = local_thought(author.id.clone()); + + let mut remote_booster = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@remote").unwrap(), + PasswordHash("h".into()), + ); + remote_booster.local = false; + + store.users.lock().unwrap().push(author); + store.users.lock().unwrap().push(remote_booster.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostAdded { + boost_id: BoostId::new(), + user_id: remote_booster.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert!(spy.announced.lock().unwrap().is_empty()); + } }