Files
k-ap/src/service/broadcast.rs
Gabriel Kaszewski b569efe715
All checks were successful
CI / fmt (push) Successful in 31s
CI / clippy (push) Successful in 4m0s
CI / test (push) Successful in 5m44s
v0.5.0 — codebase refinement, flexible API, architecture cleanup
Error handling:
  thiserror enum (NotFound/BadRequest/Unauthorized/Forbidden/Internal)
  eliminates 41 boilerplate .map_err() calls
  signature failures return 401, not 500

Named types:
  Keypair, LocalObject (with to/cc/bto/bcc addressing), Addressing

Readability:
  descriptive names everywhere, small functions, breathing room
  noisy comments removed, intent-explicit error handling (no let _ =)
  types.rs per module separating data from behavior

File organization:
  handlers/ module (actor, featured, followers, inbox, nodeinfo, outbox, webfinger)
  actors/ split (mod.rs + person.rs + types.rs)
  service/ split (builder, broadcast, collections, delivery, fetch, follow, lookup, types)
  tests next to modules

Repository traits:
  FollowRepository → 5 sub-traits (FollowerWriter/Reader, FollowingWriter/Reader, FollowMigration)
  ActorRepository → 3 sub-traits (KeypairRepository, RemoteActorCache, AnnounceRepository)
  BlocklistRepository → 2 sub-traits (DomainBlocklist, ActorBlocklist)
  supertraits with blanket impls — existing consumers unchanged
  FollowMigration has default no-op
  delete dead get_following_outbox_url

Testing:
  mock_repo! macro generates mock builders from compact specs
  MockFollowRepo, MockActorRepo, MockBlocklistRepo, MockActivityRepo,
  MockUserRepo, MockContentReader, MockObjectHandler, MockEventPublisher
  all hand-written test stubs replaced

Flexibility:
  UrlScheme trait — configurable URL patterns (DefaultUrlScheme = /users/{uuid})
  on_unknown_activity hook for custom AP extensions
  broadcast_raw_to_followers for arbitrary activity JSON
  broadcast_create/broadcast_update (renamed from Note-centric names)
  internal modules locked to pub(crate), clean public re-exports
  actor_handler, followers_handler, following_handler re-exported for custom routers

Security:
  SSRF: block IPv6-mapped private IPv4, TEST-NET, benchmarking, reserved ranges
  verify_attributed_to rejects missing/array attributedTo
  remove .expect() from outbox handler

Architecture:
  handlers/followers.rs delegates to serialize_ordered_collection (no more UrlScheme bypass)
  extract dispatch_sends, prepare_addressed_broadcast (eliminate duplication)
  DbActor::object_id(), RemoteActor::from/from_ap_person/placeholder
  send_activity unifies prepare+dispatch, deterministic_activity_id helper
  pass-through wrappers grouped in lookup.rs
2026-07-25 17:06:53 +02:00

426 lines
15 KiB
Rust

use activitypub_federation::{protocol::context::WithContext, traits::Object};
use url::Url;
use crate::{
activities::{
AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity,
UpdateActivity,
},
actors::{DbActor, get_local_actor},
data::FederationData,
user::ApVisibility,
};
use super::ActivityPubService;
use super::types::{AddRef, AddRefObject, AnnounceRef, LikeRef, TombstoneRef};
// Re-export so existing `crate::service::broadcast::{Addressing, visibility_addressing}` paths keep working.
#[allow(unused_imports)]
pub(crate) use super::types::Addressing;
pub(crate) use super::types::visibility_addressing;
fn deterministic_activity_id(
base_url: &str,
prefix: &str,
user_id: uuid::Uuid,
object_url: &Url,
) -> anyhow::Result<Url> {
let namespace_input = format!("{}/{}", user_id, object_url);
let deterministic_id =
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, namespace_input.as_bytes());
Ok(Url::parse(&format!(
"{}/activities/{}/{}",
base_url, prefix, deterministic_id
))?)
}
impl ActivityPubService {
pub async fn broadcast_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: Url,
) -> anyhow::Result<()> {
let announce_id =
deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
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: local_actor.object_id(),
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()],
};
self.send_activity(&data, &local_actor, inboxes, announce)
.await
}
pub async fn broadcast_undo_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: Url,
) -> anyhow::Result<()> {
let announce_id =
deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
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: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: local_actor.object_id(),
object: serde_json::to_value(AnnounceRef {
kind: "Announce",
id: announce_id.to_string(),
actor: local_actor.ap_id.to_string(),
object: object_ap_id.to_string(),
})?,
};
self.send_activity(&data, &local_actor, inboxes, undo).await
}
pub async fn broadcast_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: Url,
author_inbox_url: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data).await?;
let like_id =
deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
let like = crate::activities::LikeActivity {
id: like_id,
kind: Default::default(),
actor: local_actor.object_id(),
object: object_ap_id,
};
self.send_activity(&data, &local_actor, vec![author_inbox_url], like)
.await
}
pub async fn broadcast_undo_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: Url,
author_inbox_url: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data).await?;
let like_id =
deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
let undo = UndoActivity {
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: local_actor.object_id(),
object: serde_json::to_value(LikeRef {
kind: "Like",
id: like_id.to_string(),
actor: local_actor.ap_id.to_string(),
object: object_ap_id.to_string(),
})?,
};
self.send_activity(&data, &local_actor, vec![author_inbox_url], undo)
.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: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: local_actor.object_id(),
object: serde_json::to_value(TombstoneRef {
kind: "Tombstone",
id: ap_id.to_string(),
})?,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
self.send_activity(&data, &local_actor, inboxes, delete)
.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: local_actor.object_id(),
object,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
self.send_activity(&data, &local_actor, inboxes, add).await
}
pub async fn broadcast_undo_add_to_followers(
&self,
local_user_id: uuid::Uuid,
object_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: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: local_actor.object_id(),
object: serde_json::to_value(AddRef {
kind: "Add",
id: object_ap_id.to_string(),
object: AddRefObject {
id: object_ap_id.to_string(),
},
})?,
};
self.send_activity(&data, &local_actor, inboxes, undo).await
}
/// Resolve the local actor, gather follower + mentioned inboxes, and compute
/// `to`/`cc` addressing. Returns `None` when visibility is `Private` or there
/// are no inboxes to deliver to.
async fn prepare_addressed_broadcast(
&self,
local_user_id: uuid::Uuid,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<
Option<(
activitypub_federation::config::Data<FederationData>,
DbActor,
Vec<Url>,
Addressing,
)>,
> {
if visibility == ApVisibility::Private {
return Ok(None);
}
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data).await?;
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let inboxes = merge_inboxes(follower_inboxes, mentioned_inboxes);
if inboxes.is_empty() {
return Ok(None);
}
let addressing = visibility_addressing(visibility, &local_actor.followers_url);
Ok(Some((data, local_actor, inboxes, addressing)))
}
/// Fan out a Create activity to accepted followers and any explicitly
/// mentioned actors.
///
/// `visibility` controls `to`/`cc` addressing:
/// - `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 object 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(
&self,
local_user_id: uuid::Uuid,
object: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
return Ok(());
};
let object_id_str = object["id"].as_str().unwrap_or("");
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
self.base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, object_id_str.as_bytes())
))?;
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: local_actor.object_id(),
object,
to: addressing.to,
cc: addressing.cc,
bto: vec![],
bcc: vec![],
};
self.send_activity(&data, &local_actor, inboxes, create)
.await
}
/// Fan out an Update activity to accepted followers and mentioned actors.
/// See [`ActivityPubService::broadcast_create`] for `mentioned_inboxes` semantics.
pub async fn broadcast_update(
&self,
local_user_id: uuid::Uuid,
object: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
return Ok(());
};
let update = UpdateActivity {
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: local_actor.object_id(),
object,
to: addressing.to,
cc: addressing.cc,
};
self.send_activity(&data, &local_actor, inboxes, update)
.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?;
let person = local_actor.clone().into_json(&data).await?;
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: local_actor.object_id(),
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");
self.send_activity(&data, &local_actor, inboxes, update)
.await
}
pub async fn broadcast_move(
&self,
user_id: uuid::Uuid,
new_actor_url: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data).await?;
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: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: local_actor.object_id(),
object: local_actor.ap_id.clone(),
target: new_actor_url.clone(),
};
self.send_activity(&data, &local_actor, inboxes, move_activity)
.await?;
tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched");
Ok(())
}
/// Broadcast a pre-built activity to all accepted followers.
///
/// This is the low-level escape hatch for custom activity types that
/// k-ap doesn't have a dedicated method for. The `activity` JSON must
/// be a complete AP activity with `id`, `type`, `actor`, etc. already set.
/// k-ap wraps it in `@context` and handles signing + delivery.
pub async fn broadcast_raw_to_followers(
&self,
local_user_id: uuid::Uuid,
activity: 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(());
};
self.send_raw_activity(&data, &local_actor, inboxes, activity)
.await
}
}
fn merge_inboxes(follower_inboxes: Vec<String>, mentioned_inboxes: Vec<Url>) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|inbox_str| Url::parse(&inbox_str).ok())
.filter(|url| seen.insert(url.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
inboxes
}