fix: AP protocol correctness gaps

Undo(Announce): now removes announce record from ActorRepository and
  calls ApObjectHandler::on_announce_removed (default no-op, override
  to decrement boost counts). Announce counts no longer drift.

Undo(Block): now logged at info level instead of silently ignored.
  No automatic relationship restoration (spec doesn't require it).

AddActivity: now uses object["id"] as the stable ap_id (same as
  CreateActivity), falling back to activity id only if object has no
  id field. Fixes keying watchlist/collection items by the wrong id.

Featured collection: GET /users/{id}/featured now served by the router.
  ApContentReader::get_featured_objects() has a default empty-list impl
  — override to expose pinned posts without any breaking changes.
This commit is contained in:
2026-05-29 02:29:38 +02:00
parent 5288696795
commit 48fded426f
8 changed files with 117 additions and 1 deletions

View File

@@ -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)

View File

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

View File

@@ -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<u64>;
/// 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<Vec<url::Url>> {
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

42
src/featured_handler.rs Normal file
View File

@@ -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<String>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, 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::<Vec<_>>(),
})))
}

View File

@@ -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;

View File

@@ -30,5 +30,8 @@ pub trait ActorRepository: Send + Sync {
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> 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<usize>;
}

View File

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

View File

@@ -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<usize> {
Ok(0)
}