diff --git a/src/activities/add.rs b/src/activities/add.rs index 008f3f0..9076b91 100644 --- a/src/activities/add.rs +++ b/src/activities/add.rs @@ -54,7 +54,14 @@ impl Activity for AddActivity { if check_guards(&self.id, self.actor.inner(), data).await? { return Ok(()); } - let ap_id = self.id.clone(); + // Use the object's own id as the stable AP identifier, falling back to + // the activity id only if the object has no id field. + let ap_id = self + .object + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| Url::parse(s).ok()) + .unwrap_or_else(|| self.id.clone()); let actor_url = self.actor.inner().clone(); data.object_handler .on_create(&ap_id, &actor_url, self.object) diff --git a/src/activities/undo.rs b/src/activities/undo.rs index 14bc559..f382af6 100644 --- a/src/activities/undo.rs +++ b/src/activities/undo.rs @@ -97,6 +97,44 @@ impl Activity for UndoActivity { } tracing::info!(actor = %self.actor.inner(), "received Undo(Like)"); } + "Announce" => { + // Remove the boost record so announce counts stay accurate. + let activity_id = self.object.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let object_url_str = self + .object + .get("object") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if !activity_id.is_empty() + && let Err(e) = data + .actor_repo + .remove_announce(activity_id, self.actor.inner().as_str()) + .await + { + tracing::warn!(error = %e, activity_id, "failed to remove announce record"); + } + + if let Ok(obj_url) = Url::parse(object_url_str) + && obj_url.host_str().unwrap_or("") == data.domain + { + data.object_handler + .on_announce_removed(&obj_url, self.actor.inner()) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to process Undo(Announce)"); + }); + } + tracing::info!(actor = %self.actor.inner(), "received Undo(Announce)"); + } + "Block" => { + // Remote actor unblocked a local user. No automatic relationship + // restoration — the blocked user would need to re-follow manually. + tracing::info!( + actor = %self.actor.inner(), + "received Undo(Block) — no automatic action taken" + ); + } other => { tracing::debug!(kind = %other, "ignoring Undo of unknown activity type"); } diff --git a/src/content.rs b/src/content.rs index cf224e2..b5acce8 100644 --- a/src/content.rs +++ b/src/content.rs @@ -25,6 +25,17 @@ pub trait ApContentReader: Send + Sync { /// Total locally-authored posts across all users. Used by NodeInfo. async fn count_local_posts(&self) -> anyhow::Result; + + /// AP URLs of pinned (featured) objects for this user, in display order. + /// + /// Served at `GET /users/{id}/featured` as an `OrderedCollection`. + /// Mastodon and Pleroma follow this link from the actor's `featured` field. + /// + /// Defaults to an empty list — override to expose pinned posts. + async fn get_featured_objects(&self, user_id: uuid::Uuid) -> anyhow::Result> { + let _ = user_id; + Ok(vec![]) + } } /// Write side — the library calls these when processing inbound AP activities. @@ -83,6 +94,15 @@ pub trait ApObjectHandler: Send + Sync { /// separately in [`crate::repository::ActorRepository::count_announces`]. async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + /// A remote actor removed their boost (`Undo(Announce)`) of a locally-authored + /// object. Use this to decrement boost counts or update UI. + /// + /// Has a default no-op implementation — override to handle undone boosts. + async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> { + let _ = (object_url, actor_url); + Ok(()) + } + /// A remote actor boosted an object hosted on a **different server**. /// /// Use this to surface cross-server boosts in local feeds. Called instead diff --git a/src/featured_handler.rs b/src/featured_handler.rs new file mode 100644 index 0000000..23e110e --- /dev/null +++ b/src/featured_handler.rs @@ -0,0 +1,42 @@ +use activitypub_federation::{axum::json::FederationJson, config::Data}; +use axum::extract::Path; +use serde_json::json; + +use crate::data::FederationData; +use crate::error::Error; +use crate::urls::AP_CONTEXT; + +/// Serves the `featured` (pinned posts) `OrderedCollection` for a local user. +/// +/// Remote servers follow the `featured` link from the actor JSON and expect +/// an `OrderedCollection` whose `orderedItems` are the AP URLs of pinned objects. +/// The handler calls [`ApContentReader::get_featured_objects`] — override that +/// method to expose your pinned posts. +pub async fn featured_handler( + Path(user_id_str): Path, + data: Data, +) -> Result, Error> { + let user_id = uuid::Uuid::parse_str(&user_id_str) + .map_err(|_| Error::not_found(anyhow::anyhow!("user not found")))?; + + data.user_repo + .find_by_id(user_id) + .await + .map_err(Error::from)? + .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; + + let featured_url = format!("{}/users/{}/featured", data.base_url, user_id_str); + let items = data + .content_reader + .get_featured_objects(user_id) + .await + .map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?; + + Ok(FederationJson(json!({ + "@context": AP_CONTEXT, + "type": "OrderedCollection", + "id": featured_url, + "totalItems": items.len(), + "orderedItems": items.iter().map(|u| u.as_str()).collect::>(), + }))) +} diff --git a/src/lib.rs b/src/lib.rs index c146306..d3afe5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod actors; pub mod content; pub mod data; pub mod error; +pub mod featured_handler; pub mod federation; pub mod followers_handler; pub mod inbox; diff --git a/src/repository/actor.rs b/src/repository/actor.rs index dfb436e..24fb011 100644 --- a/src/repository/actor.rs +++ b/src/repository/actor.rs @@ -30,5 +30,8 @@ pub trait ActorRepository: Send + Sync { actor_url: &str, announced_at: chrono::DateTime, ) -> Result<()>; + /// Remove a boost record when a remote actor sends `Undo(Announce)`. + /// Implementations should match by `activity_id` and `actor_url`. + async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()>; async fn count_announces(&self, object_url: &str) -> Result; } diff --git a/src/service/mod.rs b/src/service/mod.rs index 606acc5..10f2cfb 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -9,6 +9,7 @@ use crate::{ actors::{DbActor, get_local_actor}, content::{ApContentReader, ApObjectHandler}, data::FederationData, + featured_handler::featured_handler, federation::ApFederationConfig, followers_handler::{followers_handler, following_handler}, inbox::inbox_handler, @@ -210,6 +211,7 @@ impl ActivityPubService { .route("/users/{id}/outbox", get(outbox_handler)) .route("/users/{id}/followers", get(followers_handler)) .route("/users/{id}/following", get(following_handler)) + .route("/users/{id}/featured", get(featured_handler)) .layer(self.federation_config.middleware()) } diff --git a/src/tests/integration.rs b/src/tests/integration.rs index ddd851e..d3a9f82 100644 --- a/src/tests/integration.rs +++ b/src/tests/integration.rs @@ -182,6 +182,9 @@ impl ActorRepository for MemActorRepo { ) -> anyhow::Result<()> { Ok(()) } + async fn remove_announce(&self, _: &str, _: &str) -> anyhow::Result<()> { + Ok(()) + } async fn count_announces(&self, _: &str) -> anyhow::Result { Ok(0) }