Files
k-ap/src/service/broadcast.rs
Gabriel Kaszewski 5288696795 fix: pre-release improvements — scale, correctness, API clarity
#1  count_accepted_followers / get_accepted_followers_page: new DB-side
    methods on FollowRepository — no more loading all followers into memory
    to count or page them.

#2  Move re-follows are now non-blocking: tokio::spawn instead of
    awaiting each sign_and_send inside receive() — inbox handler no longer
    stalls for slow remote servers during account migration.

#3  Remove get_local_objects_for_user from ApContentReader (dead code).
    Backfill and outbox both use the paginated get_local_objects_page.

#6  Rename backfill_outbox → import_remote_outbox with a clear doc
    explaining the direction (import FROM a remote server, not to a follower).

#7  also_known_as: Option<String> → Vec<String> on ApUser, LookedUpActor,
    and DbActor. from_json now stores all aliases; move_act.rs checks all.

Mentions: broadcast_create_note / broadcast_update_note now accept
    mentioned_inboxes: Vec<Url> — delivery goes to followers + mentioned
    actors who aren't already followers. Deduplication is done before
    sending. Pass vec![] if note has no external mentions.

Docs: ApObjectHandler and ApContentReader now have complete doc comments
    with contracts, idempotency guidance, and error-handling semantics.
2026-05-29 02:19:39 +02:00

435 lines
16 KiB
Rust

use activitypub_federation::{
fetch::object_id::ObjectId, protocol::context::WithContext, traits::Object,
};
use url::Url;
use crate::{
activities::{
AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity,
UpdateActivity,
},
actors::get_local_actor,
urls::activity_url,
user::ApVisibility,
};
use super::ActivityPubService;
impl ActivityPubService {
pub async fn broadcast_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: url::Url,
) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!(
"{}/activities/announce/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let announce = AnnounceActivity {
id: announce_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: object_ap_id,
published: Some(chrono::Utc::now()),
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, announce)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_undo_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: url::Url,
) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!(
"{}/activities/announce/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let undo = UndoActivity {
id: undo_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Announce","id":announce_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
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}"))?;
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 (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], like)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
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}"))?;
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 = 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 (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_delete_to_followers(
&self,
local_user_id: uuid::Uuid,
ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let delete = DeleteActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type": "Tombstone", "id": ap_id.to_string()}),
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, delete)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_add_to_followers(
&self,
local_user_id: uuid::Uuid,
ap_id: Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let add = AddActivity {
id: ap_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, add)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_undo_add_to_followers(
&self,
local_user_id: uuid::Uuid,
watchlist_entry_ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Add","id":watchlist_entry_ap_id.as_str(),"object":{"id":watchlist_entry_ap_id.as_str()}}),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
/// Fan out a Create(Note) activity to accepted followers and any explicitly
/// mentioned actors.
///
/// `visibility` controls `to`/`cc` addressing and whether the note is public:
/// - `Public` / `FollowersOnly`: delivered to followers + `mentioned_inboxes`
/// - `Private`: returns immediately — no delivery to anyone
///
/// `mentioned_inboxes` should contain the inbox URLs of remote actors
/// explicitly tagged in the note who are not already followers. Resolve them
/// via [`ActivityPubService::lookup_actor_by_handle`] before calling. Pass an
/// empty `Vec` if there are no external mentions.
pub async fn broadcast_create_note(
&self,
local_user_id: uuid::Uuid,
note: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
return Ok(());
}
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
// Merge follower inboxes with explicitly mentioned actor inboxes,
// deduplicating by string to avoid delivering the same inbox twice.
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let note_id_str = note["id"].as_str().unwrap_or("");
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
self.base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, note_id_str.as_bytes())
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
bto: vec![],
bcc: vec![],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, create)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
/// Fan out an Update(Note) activity to accepted followers and mentioned actors.
/// See [`broadcast_create_note`] for `mentioned_inboxes` semantics.
pub async fn broadcast_update_note(
&self,
local_user_id: uuid::Uuid,
note: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
return Ok(());
}
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
let update = crate::activities::UpdateActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor
.clone()
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person_json =
serde_json::to_value(WithContext::new(person, crate::urls::actor_ap_context()))?;
let update_id = Url::parse(&format!(
"{}/activities/update/{}",
self.base_url,
uuid::Uuid::new_v4()
))?;
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: person_json,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "no accepted followers, skipping actor update broadcast");
return Ok(());
};
tracing::info!(%user_id, inbox_count = inboxes.len(), "broadcasting actor update");
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_move(
&self,
user_id: uuid::Uuid,
new_actor_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "broadcast_move: no accepted followers");
return Ok(());
};
let move_activity = MoveActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: local_actor.ap_id.clone(),
target: new_actor_url.clone(),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, move_activity)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?;
tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched");
Ok(())
}
}
/// Returns `(to, cc)` addressing for the given visibility.
/// `Private` is handled before calling this (early return in broadcast methods).
pub(super) fn visibility_addressing(
visibility: ApVisibility,
followers_url: &Url,
) -> (Vec<String>, Vec<String>) {
match visibility {
ApVisibility::Public => (
vec![crate::urls::AS_PUBLIC.to_string()],
vec![followers_url.to_string()],
),
ApVisibility::FollowersOnly => (vec![followers_url.to_string()], vec![]),
ApVisibility::Private => (vec![], vec![]),
}
}