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:
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
42
src/featured_handler.rs
Normal 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<_>>(),
|
||||
})))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user