diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs index e139377..83bb7df 100644 --- a/crates/adapters/activitypub-base/src/activities.rs +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -516,6 +516,12 @@ impl Activity for AnnounceActivity { self.published.unwrap_or_else(chrono::Utc::now), ) .await?; + data.object_handler + .on_announce_received(&self.object, self.actor.inner()) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to process announce notification"); + }); tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce"); Ok(()) } @@ -533,6 +539,45 @@ pub struct LikeActivity { pub object: Url, } +#[async_trait::async_trait] +impl Activity for LikeActivity { + type DataType = FederationData; + type Error = crate::error::Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring Like from blocked domain"); + return Ok(()); + } + + // Only process if the liked object is on our instance. + if self.object.host_str().unwrap_or("") != data.domain { + return Ok(()); + } + + data.object_handler + .on_like(&self.object, self.actor.inner()) + .await + .map_err(|e| crate::error::Error::from(anyhow::anyhow!(e)))?; + + tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like"); + Ok(()) + } +} + // --- Add --- #[derive(Clone, Default, Debug, Serialize, Deserialize)] @@ -664,4 +709,6 @@ pub enum InboxActivities { Add(AddActivity), #[serde(rename = "Block")] Block(BlockActivity), + #[serde(rename = "Like")] + Like(LikeActivity), } diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index 84510a7..f03e351 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -340,6 +340,105 @@ impl ActivityPubService { Ok(()) } + /// Send a Like activity to a single inbox. + pub async fn broadcast_like_to_inbox( + &self, + liker_user_id: uuid::Uuid, + object_ap_id: url::Url, + author_inbox_url: url::Url, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(liker_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // Deterministic ID so Undo(Like) can reference the same activity. + let like_id = url::Url::parse(&format!( + "{}/activities/like/{}", + self.base_url, + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}/{}", liker_user_id, object_ap_id).as_bytes(), + ) + ))?; + + let like = crate::activities::LikeActivity { + id: like_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: object_ap_id, + }; + + let sends = SendActivityTask::prepare( + &WithContext::new_default(like), + &local_actor, + vec![author_inbox_url], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some Like deliveries failed permanently" + ); + } + Ok(()) + } + + /// Send an Undo(Like) activity to a single inbox. + pub async fn broadcast_undo_like_to_inbox( + &self, + liker_user_id: uuid::Uuid, + object_ap_id: url::Url, + author_inbox_url: url::Url, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(liker_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // Reconstruct the same deterministic like ID. + let like_id = url::Url::parse(&format!( + "{}/activities/like/{}", + self.base_url, + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!("{}/{}", liker_user_id, object_ap_id).as_bytes(), + ) + ))?; + + let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + + let undo = crate::activities::UndoActivity { + id: undo_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: serde_json::json!({ + "type": "Like", + "id": like_id.to_string(), + "actor": local_actor.ap_id.to_string(), + "object": object_ap_id.to_string(), + }), + }; + + let sends = SendActivityTask::prepare( + &WithContext::new_default(undo), + &local_actor, + vec![author_inbox_url], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!( + count = failures.len(), + "some Undo(Like) deliveries failed permanently" + ); + } + Ok(()) + } + /// Resolve a `@user@domain` handle to a `DbActor` over HTTPS directly. /// The library's `webfinger_resolve_actor` tries HTTP first in debug mode, which breaks /// on servers that don't redirect HTTP → HTTPS. @@ -1393,22 +1492,32 @@ impl domain::ports::OutboundFederationPort for ActivityPubService { async fn broadcast_like( &self, - _liker_user_id: &domain::value_objects::UserId, - _object_ap_id: &str, - _author_inbox_url: &str, + liker_user_id: &domain::value_objects::UserId, + object_ap_id: &str, + author_inbox_url: &str, ) -> Result<(), domain::errors::DomainError> { - // TODO: implement Like activity broadcasting - Ok(()) + let object = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + let inbox = url::Url::parse(author_inbox_url) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) } async fn broadcast_undo_like( &self, - _liker_user_id: &domain::value_objects::UserId, - _object_ap_id: &str, - _author_inbox_url: &str, + liker_user_id: &domain::value_objects::UserId, + object_ap_id: &str, + author_inbox_url: &str, ) -> Result<(), domain::errors::DomainError> { - // TODO: implement Undo(Like) activity broadcasting - Ok(()) + let object = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + let inbox = url::Url::parse(author_inbox_url) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) } }