feat(application): federate local likes + locality guard prevents remote boost re-broadcast

This commit is contained in:
2026-05-15 04:58:38 +02:00
parent a7527c50be
commit e04b08c202

View File

@@ -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());
}
}