v0.5.0 — codebase refinement, flexible API, architecture cleanup
All checks were successful
CI / fmt (push) Successful in 31s
CI / clippy (push) Successful in 4m0s
CI / test (push) Successful in 5m44s

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
This commit is contained in:
2026-07-25 17:01:44 +02:00
parent 17b57fb9b5
commit b569efe715
73 changed files with 3583 additions and 3160 deletions

View File

@@ -1,6 +1,4 @@
use activitypub_federation::{
activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext,
};
use activitypub_federation::{activity_sending::SendActivityTask, protocol::context::WithContext};
use url::Url;
use crate::{activities::CreateActivity, actors::get_local_actor, federation::ApFederationConfig};
@@ -30,9 +28,10 @@ impl ActivityPubService {
.build()?;
let data = self.federation_config.to_request_data();
let actor = url::Url::parse(actor_url)?;
let root: serde_json::Value = client
.get(outbox_url)
.header("Accept", "application/activity+json")
.header("Accept", crate::urls::AP_CONTENT_TYPE)
.send()
.await?
.json()
@@ -44,6 +43,7 @@ impl ActivityPubService {
return Ok(());
}
};
let mut current_url = first;
let mut visited = std::collections::HashSet::new();
loop {
@@ -57,9 +57,10 @@ impl ActivityPubService {
tracing::warn!(url = %current_url, error = %e, "backfill: SSRF check failed");
break;
}
let page: serde_json::Value = match client
.get(&current_url)
.header("Accept", "application/activity+json")
.header("Accept", crate::urls::AP_CONTENT_TYPE)
.send()
.await
{
@@ -75,6 +76,7 @@ impl ActivityPubService {
break;
}
};
if let Some(items) = page.get("orderedItems").and_then(|v| v.as_array()) {
for item in items {
let activity_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
@@ -96,11 +98,13 @@ impl ActivityPubService {
}
}
}
match page.get("next").and_then(|v| v.as_str()) {
Some(next) => current_url = next.to_string(),
None => break,
}
}
tracing::info!(outbox = %outbox_url, pages = visited.len(), "backfill complete");
Ok(())
}
@@ -150,7 +154,7 @@ impl ActivityPubService {
}
/// Execute backfill for a single follower inbox. Call this from a job-queue
/// consumer that received a [`FederationEvent::BackfillRequested`] event.
/// consumer that received a [`crate::data::FederationEvent::BackfillRequested`] event.
///
/// Sends all of `owner_user_id`'s locally-authored content to `follower_inbox_url`,
/// oldest-to-newest, with a small sleep between batches to avoid overwhelming
@@ -181,13 +185,9 @@ impl ActivityPubService {
) -> anyhow::Result<()> {
const BATCH_SIZE: usize = 20;
let data = config.to_request_data();
let local_actor = get_local_actor(owner_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(owner_user_id, &data).await?;
let inbox = Url::parse(&follower_inbox_url)?;
// Cursor-based pagination via get_local_objects_page (newest-first).
// Avoids loading the entire post history into memory at once.
let mut before: Option<chrono::DateTime<chrono::Utc>> = None;
let (mut success_count, mut failure_count, mut total) = (0usize, 0usize, 0usize);
@@ -202,25 +202,25 @@ impl ActivityPubService {
}
let is_last_page = page.len() < BATCH_SIZE;
// Advance cursor to the oldest timestamp in this page.
before = page.last().map(|(_, _, ts)| *ts);
before = page.last().map(|item| item.published_at);
for (ap_id, object_json, _ts) in &page {
for item in &page {
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes())
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, item.ap_id.as_str().as_bytes())
))?;
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: object_json.clone(),
to: vec![],
cc: vec![],
actor: local_actor.object_id(),
object: item.object.clone(),
to: item.to.clone(),
cc: item.cc.clone(),
bto: vec![],
bcc: vec![],
};
let sends = SendActivityTask::prepare(
&WithContext::new_default(create),
&local_actor,
@@ -228,6 +228,7 @@ impl ActivityPubService {
&data,
)
.await?;
total += 1;
if send_with_retry(sends, &data, max_attempts, initial_delay)
.await

View File

@@ -1,6 +1,4 @@
use activitypub_federation::{
fetch::object_id::ObjectId, protocol::context::WithContext, traits::Object,
};
use activitypub_federation::{protocol::context::WithContext, traits::Object};
use url::Url;
use crate::{
@@ -8,144 +6,138 @@ use crate::{
AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity,
UpdateActivity,
},
actors::get_local_actor,
urls::activity_url,
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::Url,
object_ap_id: 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 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: ObjectId::from(local_actor.ap_id.clone()),
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()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, announce)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
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::Url,
object_ap_id: 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 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: undo_id,
id: data.url_scheme.activity_url(&self.base_url)?,
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()}),
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(),
})?,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
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::Url,
author_inbox_url: url::Url,
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
.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 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: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
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)
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::Url,
author_inbox_url: url::Url,
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
.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 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: undo_id,
id: data.url_scheme.activity_url(&self.base_url)?,
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()}),
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(),
})?,
};
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)
self.send_activity(&data, &local_actor, vec![author_inbox_url], undo)
.await
}
@@ -160,18 +152,20 @@ impl ActivityPubService {
else {
return Ok(());
};
let delete = DeleteActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type": "Tombstone", "id": ap_id.to_string()}),
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()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, delete)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, inboxes, delete)
.await
}
@@ -187,25 +181,23 @@ impl ActivityPubService {
else {
return Ok(());
};
let add = AddActivity {
id: ap_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
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
self.send_activity(&data, &local_actor, inboxes, add).await
}
pub async fn broadcast_undo_add_to_followers(
&self,
local_user_id: uuid::Uuid,
watchlist_entry_ap_id: Url,
object_ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
@@ -213,153 +205,136 @@ impl ActivityPubService {
else {
return Ok(());
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
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()}}),
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(),
},
})?,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
self.send_activity(&data, &local_actor, inboxes, undo).await
}
/// Fan out a Create(Note) activity to accepted followers and any explicitly
/// 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 and whether the note is public:
/// `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 note who are not already followers. Resolve them
/// 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_note(
pub async fn broadcast_create(
&self,
local_user_id: uuid::Uuid,
note: serde_json::Value,
object: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
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 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, note_id_str.as_bytes())
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, object_id_str.as_bytes())
))?;
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
actor: local_actor.object_id(),
object,
to: addressing.to,
cc: addressing.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)
self.send_activity(&data, &local_actor, inboxes, create)
.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(
/// 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,
note: serde_json::Value,
object: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
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)
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
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor
.clone()
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
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!(
@@ -370,65 +345,81 @@ impl ActivityPubService {
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
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");
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, inboxes, update)
.await
}
pub async fn broadcast_move(
&self,
user_id: uuid::Uuid,
new_actor_url: url::Url,
new_actor_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 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: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
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)
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(());
};
/// Returns `(to, cc)` addressing for the given visibility.
/// `Private` is handled before calling this (early return in broadcast methods).
pub(crate) 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![]),
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
}

219
src/service/builder.rs Normal file
View File

@@ -0,0 +1,219 @@
use std::sync::Arc;
use crate::{
content::{ApContentReader, ApObjectHandler},
data::FederationData,
federation::ApFederationConfig,
repository::{ActivityRepository, ActorRepository, BlocklistRepository, FollowRepository},
url_scheme::{DefaultUrlScheme, UrlScheme},
user::ApUserRepository,
};
use super::{
ACTOR_CACHE_TTL_SECS, ActivityPubService, DELIVERY_INITIAL_DELAY_SECS, DELIVERY_MAX_ATTEMPTS,
};
pub struct ActivityPubServiceBuilder {
activity_repo: Option<Arc<dyn ActivityRepository>>,
follow_repo: Option<Arc<dyn FollowRepository>>,
actor_repo: Option<Arc<dyn ActorRepository>>,
blocklist_repo: Option<Arc<dyn BlocklistRepository>>,
user_repo: Option<Arc<dyn ApUserRepository>>,
content_reader: Option<Arc<dyn ApContentReader>>,
object_handler: Option<Arc<dyn ApObjectHandler>>,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
delivery_max_attempts: u32,
delivery_initial_delay_secs: u64,
signed_fetch_actor_id: Option<uuid::Uuid>,
actor_cache_ttl_secs: u64,
url_scheme: Option<Arc<dyn UrlScheme>>,
nodeinfo_services_inbound: Vec<String>,
nodeinfo_services_outbound: Vec<String>,
nodeinfo_metadata: serde_json::Value,
}
impl ActivityPubServiceBuilder {
pub(super) fn new(base_url: String) -> Self {
Self {
activity_repo: None,
follow_repo: None,
actor_repo: None,
blocklist_repo: None,
user_repo: None,
content_reader: None,
object_handler: None,
base_url,
allow_registration: false,
software_name: String::new(),
debug: false,
event_publisher: None,
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
signed_fetch_actor_id: None,
actor_cache_ttl_secs: ACTOR_CACHE_TTL_SECS,
url_scheme: None,
nodeinfo_services_inbound: vec![],
nodeinfo_services_outbound: vec![],
nodeinfo_metadata: serde_json::json!({}),
}
}
pub fn activity_repo(mut self, activity_repo: Arc<dyn ActivityRepository>) -> Self {
self.activity_repo = Some(activity_repo);
self
}
pub fn follow_repo(mut self, follow_repo: Arc<dyn FollowRepository>) -> Self {
self.follow_repo = Some(follow_repo);
self
}
pub fn actor_repo(mut self, actor_repo: Arc<dyn ActorRepository>) -> Self {
self.actor_repo = Some(actor_repo);
self
}
pub fn blocklist_repo(mut self, blocklist_repo: Arc<dyn BlocklistRepository>) -> Self {
self.blocklist_repo = Some(blocklist_repo);
self
}
pub fn user_repo(mut self, user_repo: Arc<dyn ApUserRepository>) -> Self {
self.user_repo = Some(user_repo);
self
}
pub fn content_reader(mut self, content_reader: Arc<dyn ApContentReader>) -> Self {
self.content_reader = Some(content_reader);
self
}
pub fn object_handler(mut self, object_handler: Arc<dyn ApObjectHandler>) -> Self {
self.object_handler = Some(object_handler);
self
}
pub fn allow_registration(mut self, allow_registration: bool) -> Self {
self.allow_registration = allow_registration;
self
}
pub fn software_name(mut self, software_name: impl Into<String>) -> Self {
self.software_name = software_name.into();
self
}
pub fn debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
pub fn event_publisher(
mut self,
event_publisher: Arc<dyn crate::data::EventPublisher>,
) -> Self {
self.event_publisher = Some(event_publisher);
self
}
pub fn delivery_max_attempts(mut self, delivery_max_attempts: u32) -> Self {
self.delivery_max_attempts = delivery_max_attempts;
self
}
pub fn delivery_initial_delay_secs(mut self, delivery_initial_delay_secs: u64) -> Self {
self.delivery_initial_delay_secs = delivery_initial_delay_secs;
self
}
pub fn actor_cache_ttl_secs(mut self, actor_cache_ttl_secs: u64) -> Self {
self.actor_cache_ttl_secs = actor_cache_ttl_secs;
self
}
pub fn nodeinfo_services(mut self, inbound: Vec<String>, outbound: Vec<String>) -> Self {
self.nodeinfo_services_inbound = inbound;
self.nodeinfo_services_outbound = outbound;
self
}
pub fn nodeinfo_metadata(mut self, metadata: serde_json::Value) -> Self {
self.nodeinfo_metadata = metadata;
self
}
/// Override the default `/users/{uuid}` URL scheme. Consumers with custom
/// actor paths should implement [`UrlScheme`] and pass it here.
pub fn url_scheme(mut self, url_scheme: Arc<dyn UrlScheme>) -> Self {
self.url_scheme = Some(url_scheme);
self
}
/// Set a local actor whose keypair signs all outgoing fetch requests
/// (HTTP Signature on GETs). Required for federating with instances
/// that enforce authorized-fetch / Secure Mode.
pub fn signed_fetch_actor_id(mut self, signed_fetch_actor_id: uuid::Uuid) -> Self {
self.signed_fetch_actor_id = Some(signed_fetch_actor_id);
self
}
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
let activity_repo = self
.activity_repo
.ok_or_else(|| anyhow::anyhow!("activity_repo required — call .activity_repo(arc)"))?;
let follow_repo = self
.follow_repo
.ok_or_else(|| anyhow::anyhow!("follow_repo required — call .follow_repo(arc)"))?;
let actor_repo = self
.actor_repo
.ok_or_else(|| anyhow::anyhow!("actor_repo required — call .actor_repo(arc)"))?;
let blocklist_repo = self.blocklist_repo.ok_or_else(|| {
anyhow::anyhow!("blocklist_repo required — call .blocklist_repo(arc)")
})?;
let user_repo = self
.user_repo
.ok_or_else(|| anyhow::anyhow!("user_repo required — call .user_repo(arc)"))?;
let content_reader = self.content_reader.ok_or_else(|| {
anyhow::anyhow!("content_reader required — call .content_reader(arc)")
})?;
let object_handler = self.object_handler.ok_or_else(|| {
anyhow::anyhow!("object_handler required — call .object_handler(arc)")
})?;
let url_scheme = self
.url_scheme
.unwrap_or_else(|| Arc::new(DefaultUrlScheme));
let data = FederationData::new(
activity_repo,
follow_repo,
actor_repo.clone(),
blocklist_repo,
user_repo.clone(),
content_reader,
object_handler,
self.base_url.clone(),
self.allow_registration,
self.software_name,
self.event_publisher,
std::time::Duration::from_secs(self.actor_cache_ttl_secs),
url_scheme,
)
.with_nodeinfo_services(
self.nodeinfo_services_inbound,
self.nodeinfo_services_outbound,
)
.with_nodeinfo_metadata(self.nodeinfo_metadata);
let signing_actor = if let Some(uid) = self.signed_fetch_actor_id {
let actor = crate::actors::build_local_actor(
uid,
&self.base_url,
user_repo.as_ref(),
actor_repo.as_ref(),
data.url_scheme.as_ref(),
)
.await?;
Some(actor)
} else {
None
};
let federation_config =
ApFederationConfig::new(data, self.debug, signing_actor.as_ref()).await?;
Ok(ActivityPubService {
federation_config,
base_url: self.base_url,
delivery_max_attempts: self.delivery_max_attempts,
delivery_initial_delay_secs: self.delivery_initial_delay_secs,
})
}
}

107
src/service/collections.rs Normal file
View File

@@ -0,0 +1,107 @@
use activitypub_federation::{protocol::context::WithContext, traits::Object};
use crate::actors::get_local_actor;
use super::ActivityPubService;
impl ActivityPubService {
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
let uuid = uuid::Uuid::parse_str(user_id_str)?;
let data = self.federation_config.to_request_data();
let actor = get_local_actor(uuid, &data).await?;
let person = actor.into_json(&data).await?;
Ok(serde_json::to_string(&WithContext::new(
person,
crate::urls::actor_ap_context(),
))?)
}
pub async fn followers_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
let data = self.federation_config.to_request_data();
let actor_url = data.url_scheme.actor_url(&self.base_url, user_id)?;
let collection_url = data.url_scheme.followers_url(&actor_url)?.to_string();
let total = data.follow_repo.count_followers(user_id).await?;
let items_fn = |offset: u32, limit: usize| {
let data = data.clone();
async move {
Ok(data
.follow_repo
.get_followers_page(user_id, offset, limit)
.await?
.into_iter()
.map(|follower| follower.actor.url)
.collect())
}
};
serialize_ordered_collection(&collection_url, total, page, items_fn).await
}
pub async fn following_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
let data = self.federation_config.to_request_data();
let actor_url = data.url_scheme.actor_url(&self.base_url, user_id)?;
let collection_url = data.url_scheme.following_url(&actor_url)?.to_string();
let total = data.follow_repo.count_following(user_id).await?;
let items_fn = |offset: u32, limit: usize| {
let data = data.clone();
async move {
Ok(data
.follow_repo
.get_following_page(user_id, offset, limit)
.await?
.into_iter()
.map(|actor| actor.url)
.collect())
}
};
serialize_ordered_collection(&collection_url, total, page, items_fn).await
}
}
pub(crate) async fn serialize_ordered_collection<F, Fut>(
collection_url: &str,
total: usize,
page: Option<u32>,
fetch_items: F,
) -> anyhow::Result<String>
where
F: FnOnce(u32, usize) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<Vec<String>>>,
{
use crate::urls::{AP_CONTEXT, AP_PAGE_SIZE};
let json = if let Some(page_number) = page {
let page_number = page_number.max(1);
let offset = (page_number.saturating_sub(1) as usize) * AP_PAGE_SIZE;
let items = fetch_items(offset as u32, AP_PAGE_SIZE).await?;
let has_next = offset + items.len() < total;
let mut obj = serde_json::json!({
"@context": AP_CONTEXT,
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_url, page_number),
"partOf": collection_url,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_url, page_number + 1));
}
obj
} else {
serde_json::json!({
"@context": AP_CONTEXT,
"type": "OrderedCollection",
"id": collection_url,
"totalItems": total,
"first": format!("{}?page=1", collection_url),
})
};
Ok(serde_json::to_string(&json)?)
}

View File

@@ -84,28 +84,32 @@ impl Activity for RawActivity {
}
impl ActivityPubService {
/// Route deliveries to the EventPublisher (one DeliveryRequested event per inbox)
/// or fall back to a fire-and-forget tokio::spawn.
/// `pub(crate)` so sibling modules (broadcast.rs, follow.rs) can call it on `self`.
pub(crate) async fn dispatch_deliveries(
/// Dispatch pre-built `SendActivityTask`s via the event publisher (if configured)
/// or by spawning a background retry loop.
fn dispatch_sends(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor,
inboxes: Vec<Url>,
sends: Vec<SendActivityTask>,
activity_json: serde_json::Value,
) -> anyhow::Result<()> {
) {
if let Some(publisher) = data.event_publisher.as_ref() {
for inbox in inboxes {
let event = FederationEvent::DeliveryRequested {
inbox,
activity: activity_json.clone(),
signing_actor_id: local_actor.user_id,
};
if let Err(e) = publisher.publish(event).await {
tracing::warn!(error = %e, "failed to enqueue DeliveryRequested event");
let publisher = publisher.clone();
let signing_actor_id = local_actor.user_id;
let activity = activity_json;
tokio::spawn(async move {
for inbox in inboxes {
let event = FederationEvent::DeliveryRequested {
inbox,
activity: activity.clone(),
signing_actor_id,
};
if let Err(error) = publisher.publish(event).await {
tracing::warn!(%error, "failed to enqueue DeliveryRequested event");
}
}
}
});
} else {
let data = data.clone();
let max_attempts = self.delivery_max_attempts;
@@ -117,7 +121,6 @@ impl ActivityPubService {
}
});
}
Ok(())
}
/// Deliver a single outbound activity to `inbox`.
@@ -129,9 +132,8 @@ impl ActivityPubService {
signing_actor_id: uuid::Uuid,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let actor = get_local_actor(signing_actor_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let actor = get_local_actor(signing_actor_id, &data).await?;
let id = activity
.get("id")
.and_then(|v| v.as_str())
@@ -147,6 +149,7 @@ impl ActivityPubService {
actor_url,
value: activity.clone(),
};
let sends = SendActivityTask::prepare(&raw, &actor, vec![inbox.clone()], &data).await?;
let failures = send_with_retry(
sends,
@@ -158,34 +161,35 @@ impl ActivityPubService {
if failures.is_empty() {
return Ok(());
}
let error_msg = failures
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
if let Some(publisher) = data.event_publisher.as_ref() {
let _ = publisher
if let Some(publisher) = data.event_publisher.as_ref()
&& let Err(error) = publisher
.publish(FederationEvent::DeliveryFailed {
inbox,
activity,
signing_actor_id,
error: error_msg.clone(),
})
.await;
.await
{
tracing::warn!(%error, "failed to publish DeliveryFailed event");
}
Err(anyhow::anyhow!("delivery failed: {}", error_msg))
}
/// Serialize `activity` to JSON and prepare `SendActivityTask` objects.
/// Returns `(activity_json, sends, inboxes)` so both dispatch paths have what they need.
/// `pub(super)` — visible to all child modules of `service` (broadcast.rs, follow.rs, etc.).
pub(super) async fn prepare_broadcast<A>(
pub(super) async fn send_activity<A>(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor,
inboxes: Vec<Url>,
activity: A,
) -> anyhow::Result<(serde_json::Value, Vec<SendActivityTask>, Vec<Url>)>
) -> anyhow::Result<()>
where
A: Activity + Serialize + Debug + Send + Sync,
{
@@ -194,6 +198,35 @@ impl ActivityPubService {
let activity_json = serde_json::to_value(&with_ctx)?;
let sends =
SendActivityTask::prepare(&with_ctx, local_actor, inboxes.clone(), data).await?;
Ok((activity_json, sends, inboxes))
self.dispatch_sends(data, local_actor, inboxes, sends, activity_json);
Ok(())
}
pub(super) async fn send_raw_activity(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor,
inboxes: Vec<Url>,
activity: serde_json::Value,
) -> anyhow::Result<()> {
let id = activity
.get("id")
.and_then(|value| value.as_str())
.and_then(|id_str| Url::parse(id_str).ok())
.unwrap_or_else(|| local_actor.ap_id.clone());
let actor_url = activity
.get("actor")
.and_then(|value| value.as_str())
.and_then(|actor_str| Url::parse(actor_str).ok())
.unwrap_or_else(|| local_actor.ap_id.clone());
let raw = RawActivity {
id,
actor_url,
value: activity.clone(),
};
let sends = SendActivityTask::prepare(&raw, local_actor, inboxes.clone(), data).await?;
self.dispatch_sends(data, local_actor, inboxes, sends, activity);
Ok(())
}
}

View File

@@ -17,8 +17,7 @@ impl ActivityPubService {
crate::data::FederationData,
serde_json::Value,
>(url, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
.await?;
Ok(res.object)
}

View File

@@ -6,7 +6,6 @@ use crate::{
actors::get_local_actor,
data::FederationData,
repository::{FollowerStatus, FollowingStatus, RemoteActor},
urls::activity_url,
};
use super::ActivityPubService;
@@ -19,51 +18,26 @@ impl ActivityPubService {
if parts.len() == 2 && parts[1] == data.domain {
return self.follow_local(local_user_id, parts[0], &data).await;
}
let remote_actor = self.webfinger_https(handle, &data).await?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, &data).await?;
let follow_id = data.url_scheme.activity_url(&self.base_url)?;
let follow_id_str = follow_id.to_string();
let remote = RemoteActor {
url: remote_actor.ap_id.to_string(),
handle: format!(
"{}@{}",
remote_actor.username,
remote_actor.ap_id.host_str().unwrap_or("")
),
inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: remote_actor
.shared_inbox_url
.as_ref()
.map(|u| u.to_string()),
display_name: remote_actor
.display_name
.clone()
.or_else(|| Some(remote_actor.username.clone())),
avatar_url: remote_actor.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: Some(remote_actor.outbox_url.to_string()),
bio: remote_actor.bio.clone(),
banner_url: remote_actor.banner_url.as_ref().map(|u| u.to_string()),
followers_url: Some(remote_actor.followers_url.to_string()),
following_url: Some(remote_actor.following_url.to_string()),
also_known_as: remote_actor.also_known_as.clone(),
fetched_at: Some(chrono::Utc::now()),
};
let remote = RemoteActor::from(&remote_actor);
// Save BEFORE delivering — prevents lost state on process restart.
data.follow_repo
.add_following(local_user_id, remote, &follow_id_str)
.await?;
let follow = FollowActivity {
id: Url::parse(&follow_id_str)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: ObjectId::from(remote_actor.ap_id.clone()),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![remote_actor.inbox()], follow)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![remote_actor.inbox()], follow)
.await
}
@@ -78,14 +52,13 @@ impl ActivityPubService {
.unfollow_local(local_user_id, actor_url_str, &data)
.await;
}
let remote = data
.actor_repo
.get_remote_actor(actor_url_str)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, &data).await?;
let remote_ap_id = Url::parse(actor_url_str)?;
let inbox = Url::parse(&remote.inbox_url)?;
let follow_id = data
@@ -94,25 +67,27 @@ impl ActivityPubService {
.await?
.and_then(|id| Url::parse(&id).ok())
.unwrap_or_else(|| {
activity_url(&self.base_url).unwrap_or_else(|_| remote_ap_id.clone())
data.url_scheme
.activity_url(&self.base_url)
.unwrap_or_else(|_| remote_ap_id.clone())
});
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: ObjectId::from(remote_ap_id),
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?,
actor: local_actor.object_id(),
object: serde_json::to_value(&follow)?,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![inbox], undo)
.await?;
data.follow_repo
.remove_following(local_user_id, actor_url_str)
.await?;
@@ -128,9 +103,7 @@ impl ActivityPubService {
remote_actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, &data).await?;
let remote_actor = data
.actor_repo
.get_remote_actor(remote_actor_url)
@@ -143,27 +116,27 @@ impl ActivityPubService {
.ok_or_else(|| {
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
})?;
let follow = FollowActivity {
id: Url::parse(&follow_id_str)?,
kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()),
object: local_actor.object_id(),
};
let accept = AcceptActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: follow,
};
data.follow_repo
.update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted)
.await?;
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], accept)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![inbox], accept)
.await?;
let target_inbox = remote_actor
.shared_inbox_url
.clone()
@@ -178,32 +151,30 @@ impl ActivityPubService {
remote_actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, &data).await?;
let remote_actor = data
.actor_repo
.get_remote_actor(remote_actor_url)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
let follow = FollowActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()),
object: local_actor.object_id(),
};
let reject = RejectActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: follow,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], reject)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![inbox], reject)
.await?;
data.follow_repo
.remove_follower(local_user_id, remote_actor_url)
.await?;
@@ -243,8 +214,8 @@ impl ActivityPubService {
.get_followers(local_user_id)
.await?
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.map(|f| f.actor)
.filter(|follower| follower.status == FollowerStatus::Accepted)
.map(|follower| follower.actor)
.collect())
}
@@ -292,29 +263,32 @@ impl ActivityPubService {
data.blocklist_repo
.add_blocked_actor(local_user_id, actor_url)
.await?;
let _ = data
if let Err(error) = data
.follow_repo
.remove_follower(local_user_id, actor_url)
.await;
let _ = data
.await
{
tracing::debug!(%error, "follower already removed");
}
if let Err(error) = data
.follow_repo
.remove_following(local_user_id, actor_url)
.await;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
{
tracing::debug!(%error, "following already removed");
}
let local_actor = get_local_actor(local_user_id, &data).await?;
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
let block = crate::activities::BlockActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: Url::parse(actor_url)?,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], block)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![inbox], block)
.await?;
}
Ok(())
@@ -329,27 +303,24 @@ impl ActivityPubService {
data.blocklist_repo
.remove_blocked_actor(local_user_id, actor_url)
.await?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, &data).await?;
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
let block = crate::activities::BlockActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: Url::parse(actor_url)?,
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::to_value(&block).map_err(|e| anyhow::anyhow!("{e}"))?,
actor: local_actor.object_id(),
object: serde_json::to_value(&block)?,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![inbox], undo)
.await?;
tracing::info!(actor = %actor_url, "sent Undo(Block)");
}
@@ -368,22 +339,8 @@ impl ActivityPubService {
let mut actors = Vec::new();
for url in actor_urls {
let actor = match data.actor_repo.get_remote_actor(&url).await {
Ok(Some(a)) => a,
_ => RemoteActor {
url: url.clone(),
handle: url.clone(),
inbox_url: url.clone(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
outbox_url: None,
bio: None,
banner_url: None,
followers_url: None,
following_url: None,
also_known_as: vec![],
fetched_at: None,
},
Ok(Some(cached)) => cached,
_ => RemoteActor::placeholder(url),
};
actors.push(actor);
}
@@ -404,11 +361,14 @@ impl ActivityPubService {
if target.id == local_user_id {
return Err(anyhow::anyhow!("cannot follow yourself"));
}
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id);
let follow_id = activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?
let follower_actor_url = data
.url_scheme
.actor_url(&self.base_url, local_user_id)?
.to_string();
let target_actor_url = data.url_scheme.actor_url(&self.base_url, target.id)?;
let follow_id = data.url_scheme.activity_url(&self.base_url)?.to_string();
data.follow_repo
.add_follower(
target.id,
@@ -417,21 +377,31 @@ impl ActivityPubService {
&follow_id,
)
.await?;
let target_as_remote = RemoteActor {
url: target_actor_url.to_string(),
handle: format!("{}@{}", target.username, data.domain),
inbox_url: format!("{}/inbox", target_actor_url),
inbox_url: data.url_scheme.inbox_url(&target_actor_url)?.to_string(),
shared_inbox_url: None,
display_name: target.display_name.or(Some(target.username)),
avatar_url: target.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: Some(format!("{}/outbox", target_actor_url)),
avatar_url: target.avatar_url.as_ref().map(|url| url.to_string()),
outbox_url: Some(data.url_scheme.outbox_url(&target_actor_url)?.to_string()),
bio: target.bio,
banner_url: target.banner_url.as_ref().map(|u| u.to_string()),
followers_url: Some(format!("{}/followers", target_actor_url)),
following_url: Some(format!("{}/following", target_actor_url)),
banner_url: target.banner_url.as_ref().map(|url| url.to_string()),
followers_url: Some(
data.url_scheme
.followers_url(&target_actor_url)?
.to_string(),
),
following_url: Some(
data.url_scheme
.following_url(&target_actor_url)?
.to_string(),
),
also_known_as: target.also_known_as,
fetched_at: None,
};
data.follow_repo
.add_following(local_user_id, target_as_remote, &follow_id)
.await?;
@@ -442,6 +412,7 @@ impl ActivityPubService {
FollowingStatus::Accepted,
)
.await?;
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
Ok(())
}
@@ -453,15 +424,22 @@ impl ActivityPubService {
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<()> {
let target_url = Url::parse(target_actor_url)?;
let target_user_id = crate::urls::extract_user_id_from_url(&target_url)
let target_user_id = data
.url_scheme
.extract_user_id(&target_url)
.ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?;
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
let local_actor_url = data
.url_scheme
.actor_url(&self.base_url, local_user_id)?
.to_string();
data.follow_repo
.remove_follower(target_user_id, &local_actor_url)
.await?;
data.follow_repo
.remove_following(local_user_id, target_actor_url)
.await?;
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
Ok(())
}

152
src/service/lookup.rs Normal file
View File

@@ -0,0 +1,152 @@
use url::Url;
use crate::{actors::DbActor, data::FederationData, repository::BlockedDomain};
use super::ActivityPubService;
struct ParsedHandle<'a> {
username: &'a str,
domain: &'a str,
}
fn parse_handle(handle: &str) -> anyhow::Result<ParsedHandle<'_>> {
let normalized = handle.trim_start_matches('@');
let separator_index = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
Ok(ParsedHandle {
username: &normalized[..separator_index],
domain: &normalized[separator_index + 1..],
})
}
fn webfinger_url(handle: &ParsedHandle<'_>) -> String {
format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
handle.domain, handle.username, handle.domain
)
}
async fn fetch_webfinger(url: &str) -> anyhow::Result<serde_json::Value> {
let parsed = Url::parse(url)?;
crate::security::validate_url(&parsed).await?;
Ok(reqwest::Client::new()
.get(url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?)
}
fn extract_actor_href(webfinger: &serde_json::Value) -> anyhow::Result<String> {
webfinger["links"]
.as_array()
.and_then(|links| {
links.iter().find(|link| {
link["rel"].as_str() == Some("self")
&& link["type"].as_str() == Some(crate::urls::AP_CONTENT_TYPE)
})
})
.and_then(|link| link["href"].as_str())
.map(|href| href.to_owned())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))
}
impl ActivityPubService {
// ── Pass-through wrappers ───────────────────────────────────────────
pub async fn mark_follower_accepted(
&self,
user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo
.update_follower_status(
user_id,
actor_url,
crate::repository::FollowerStatus::Accepted,
)
.await
}
pub async fn mark_follower_rejected(
&self,
user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo.remove_follower(user_id, actor_url).await
}
pub async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.add_blocked_domain(domain, reason).await
}
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.remove_blocked_domain(domain).await
}
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.get_blocked_domains().await
}
// ── WebFinger / actor resolution ────────────────────────────────────
pub async fn lookup_actor_by_handle(
&self,
handle: &str,
) -> anyhow::Result<crate::user::LookedUpActor> {
tracing::info!(handle, "looking up remote actor");
let data = self.federation_config.to_request_data();
let actor = self
.webfinger_https(handle, &data)
.await
.inspect_err(|error| tracing::warn!(handle, %error, "actor lookup failed"))?;
let domain = actor.ap_id.host_str().unwrap_or("").to_string();
tracing::info!(handle = format!("{}@{}", actor.username, domain), ap_url = %actor.ap_id, "remote actor resolved");
Ok(crate::user::LookedUpActor {
handle: format!("{}@{}", actor.username, domain),
display_name: actor.display_name,
bio: actor.bio,
avatar_url: actor.avatar_url,
banner_url: actor.banner_url,
ap_url: actor.ap_id,
outbox_url: Some(actor.outbox_url),
followers_url: Some(actor.followers_url),
following_url: Some(actor.following_url),
also_known_as: actor.also_known_as,
profile_url: actor.profile_url,
attachment: actor.attachment,
})
}
pub(super) async fn webfinger_https(
&self,
handle: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<DbActor> {
let parsed_handle = parse_handle(handle)?;
let url = webfinger_url(&parsed_handle);
tracing::debug!(handle, webfinger_url = %url, "resolving webfinger");
let webfinger_response = fetch_webfinger(&url).await?;
let actor_href = extract_actor_href(&webfinger_response)?;
tracing::debug!(handle, actor_href, "webfinger resolved, fetching actor");
let actor: DbActor =
activitypub_federation::fetch::object_id::ObjectId::from(Url::parse(&actor_href)?)
.dereference(data)
.await?;
Ok(actor)
}
}

View File

@@ -1,30 +1,31 @@
use std::sync::Arc;
use activitypub_federation::{protocol::context::WithContext, traits::Object};
use axum::{Router, extract::DefaultBodyLimit, routing::get, routing::post};
use url::Url;
use axum::{Router, extract::DefaultBodyLimit, routing::get, routing::post};
use crate::{
actors::{DbActor, get_local_actor},
content::{ApContentReader, ApObjectHandler},
data::FederationData,
featured_handler::featured_handler,
federation::ApFederationConfig,
inbox::inbox_handler,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
outbox::outbox_handler,
repository::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
handlers::{
featured::featured_handler,
inbox::inbox_handler,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
outbox::outbox_handler,
webfinger::webfinger_handler,
},
user::ApUserRepository,
webfinger::webfinger_handler,
};
mod backfill;
pub(crate) mod broadcast;
mod builder;
pub(crate) mod collections;
pub(super) mod delivery;
mod fetch;
mod follow;
mod lookup;
pub(crate) mod types;
pub use builder::ActivityPubServiceBuilder;
/// Default max delivery retries per inbox (used as the builder default).
pub const DELIVERY_MAX_ATTEMPTS: u32 = 3;
@@ -45,195 +46,9 @@ pub struct ActivityPubService {
pub(super) delivery_initial_delay_secs: u64,
}
pub struct ActivityPubServiceBuilder {
activity_repo: Option<Arc<dyn ActivityRepository>>,
follow_repo: Option<Arc<dyn FollowRepository>>,
actor_repo: Option<Arc<dyn ActorRepository>>,
blocklist_repo: Option<Arc<dyn BlocklistRepository>>,
user_repo: Option<Arc<dyn ApUserRepository>>,
content_reader: Option<Arc<dyn ApContentReader>>,
object_handler: Option<Arc<dyn ApObjectHandler>>,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
delivery_max_attempts: u32,
delivery_initial_delay_secs: u64,
signed_fetch_actor_id: Option<uuid::Uuid>,
actor_cache_ttl_secs: u64,
nodeinfo_services_inbound: Vec<String>,
nodeinfo_services_outbound: Vec<String>,
nodeinfo_metadata: serde_json::Value,
}
impl ActivityPubServiceBuilder {
pub fn activity_repo(mut self, v: Arc<dyn ActivityRepository>) -> Self {
self.activity_repo = Some(v);
self
}
pub fn follow_repo(mut self, v: Arc<dyn FollowRepository>) -> Self {
self.follow_repo = Some(v);
self
}
pub fn actor_repo(mut self, v: Arc<dyn ActorRepository>) -> Self {
self.actor_repo = Some(v);
self
}
pub fn blocklist_repo(mut self, v: Arc<dyn BlocklistRepository>) -> Self {
self.blocklist_repo = Some(v);
self
}
pub fn user_repo(mut self, v: Arc<dyn ApUserRepository>) -> Self {
self.user_repo = Some(v);
self
}
pub fn content_reader(mut self, v: Arc<dyn ApContentReader>) -> Self {
self.content_reader = Some(v);
self
}
pub fn object_handler(mut self, v: Arc<dyn ApObjectHandler>) -> Self {
self.object_handler = Some(v);
self
}
pub fn allow_registration(mut self, v: bool) -> Self {
self.allow_registration = v;
self
}
pub fn software_name(mut self, v: impl Into<String>) -> Self {
self.software_name = v.into();
self
}
pub fn debug(mut self, v: bool) -> Self {
self.debug = v;
self
}
pub fn event_publisher(mut self, v: Arc<dyn crate::data::EventPublisher>) -> Self {
self.event_publisher = Some(v);
self
}
pub fn delivery_max_attempts(mut self, v: u32) -> Self {
self.delivery_max_attempts = v;
self
}
pub fn delivery_initial_delay_secs(mut self, v: u64) -> Self {
self.delivery_initial_delay_secs = v;
self
}
/// How long cached remote actors are considered fresh (seconds, default 24h).
/// After this duration, the next access re-fetches the actor from origin.
pub fn actor_cache_ttl_secs(mut self, v: u64) -> Self {
self.actor_cache_ttl_secs = v;
self
}
pub fn nodeinfo_services(mut self, inbound: Vec<String>, outbound: Vec<String>) -> Self {
self.nodeinfo_services_inbound = inbound;
self.nodeinfo_services_outbound = outbound;
self
}
pub fn nodeinfo_metadata(mut self, metadata: serde_json::Value) -> Self {
self.nodeinfo_metadata = metadata;
self
}
/// Set a local actor whose keypair signs all outgoing fetch requests
/// (HTTP Signature on GETs). Required for federating with instances
/// that enforce authorized-fetch / Secure Mode.
pub fn signed_fetch_actor_id(mut self, v: uuid::Uuid) -> Self {
self.signed_fetch_actor_id = Some(v);
self
}
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
let activity_repo = self
.activity_repo
.ok_or_else(|| anyhow::anyhow!("activity_repo required — call .activity_repo(arc)"))?;
let follow_repo = self
.follow_repo
.ok_or_else(|| anyhow::anyhow!("follow_repo required — call .follow_repo(arc)"))?;
let actor_repo = self
.actor_repo
.ok_or_else(|| anyhow::anyhow!("actor_repo required — call .actor_repo(arc)"))?;
let blocklist_repo = self.blocklist_repo.ok_or_else(|| {
anyhow::anyhow!("blocklist_repo required — call .blocklist_repo(arc)")
})?;
let user_repo = self
.user_repo
.ok_or_else(|| anyhow::anyhow!("user_repo required — call .user_repo(arc)"))?;
let content_reader = self.content_reader.ok_or_else(|| {
anyhow::anyhow!("content_reader required — call .content_reader(arc)")
})?;
let object_handler = self.object_handler.ok_or_else(|| {
anyhow::anyhow!("object_handler required — call .object_handler(arc)")
})?;
let data = FederationData::new(
activity_repo,
follow_repo,
actor_repo.clone(),
blocklist_repo,
user_repo.clone(),
content_reader,
object_handler,
self.base_url.clone(),
self.allow_registration,
self.software_name,
self.event_publisher,
std::time::Duration::from_secs(self.actor_cache_ttl_secs),
)
.with_nodeinfo_services(
self.nodeinfo_services_inbound,
self.nodeinfo_services_outbound,
)
.with_nodeinfo_metadata(self.nodeinfo_metadata);
let signing_actor = if let Some(uid) = self.signed_fetch_actor_id {
let actor = crate::actors::build_local_actor(
uid,
&self.base_url,
user_repo.as_ref(),
actor_repo.as_ref(),
)
.await?;
Some(actor)
} else {
None
};
let federation_config =
ApFederationConfig::new(data, self.debug, signing_actor.as_ref()).await?;
Ok(ActivityPubService {
federation_config,
base_url: self.base_url,
delivery_max_attempts: self.delivery_max_attempts,
delivery_initial_delay_secs: self.delivery_initial_delay_secs,
})
}
}
impl ActivityPubService {
pub fn builder(base_url: impl Into<String>) -> ActivityPubServiceBuilder {
ActivityPubServiceBuilder {
activity_repo: None,
follow_repo: None,
actor_repo: None,
blocklist_repo: None,
user_repo: None,
content_reader: None,
object_handler: None,
base_url: base_url.into(),
allow_registration: false,
software_name: String::new(),
debug: false,
event_publisher: None,
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
signed_fetch_actor_id: None,
actor_cache_ttl_secs: ACTOR_CACHE_TTL_SECS,
nodeinfo_services_inbound: vec![],
nodeinfo_services_outbound: vec![],
nodeinfo_metadata: serde_json::json!({}),
}
ActivityPubServiceBuilder::new(base_url.into())
}
pub fn federation_config(&self) -> &ApFederationConfig {
@@ -269,178 +84,23 @@ impl ActivityPubService {
.route("/.well-known/webfinger", get(webfinger_handler))
.route(
"/inbox",
post(inbox_handler).layer(DefaultBodyLimit::max(1024 * 1024)),
post(inbox_handler).layer(DefaultBodyLimit::max(crate::urls::INBOX_BODY_LIMIT)),
)
.route(
"/users/{id}/inbox",
post(inbox_handler).layer(DefaultBodyLimit::max(1024 * 1024)),
post(inbox_handler).layer(DefaultBodyLimit::max(crate::urls::INBOX_BODY_LIMIT)),
)
.route("/users/{id}/outbox", get(outbox_handler))
.route("/users/{id}/featured", get(featured_handler))
.layer(self.federation_config.middleware())
}
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
let uuid = uuid::Uuid::parse_str(user_id_str)?;
let data = self.federation_config.to_request_data();
let actor = get_local_actor(uuid, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = actor
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(serde_json::to_string(&WithContext::new(
person,
crate::urls::actor_ap_context(),
))?)
}
pub async fn followers_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
const PAGE_SIZE: usize = 20;
let data = self.federation_config.to_request_data();
let collection_id = format!("{}/users/{}/followers", self.base_url, user_id);
let total = data.follow_repo.count_followers(user_id).await?;
let obj = if let Some(p) = page {
let p = p.max(1);
let offset = (p.saturating_sub(1) as usize) * PAGE_SIZE;
let followers = data
.follow_repo
.get_followers_page(user_id, offset as u32, PAGE_SIZE)
.await?;
let has_next = offset + followers.len() < total;
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
let mut obj = serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollectionPage","id":format!("{}?page={}",collection_id,p),"partOf":collection_id,"totalItems":total,"orderedItems":items});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
}
obj
} else {
serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollection","id":collection_id,"totalItems":total,"first":format!("{}?page=1",collection_id)})
};
Ok(serde_json::to_string(&obj)?)
}
pub async fn following_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
const PAGE_SIZE: usize = 20;
let data = self.federation_config.to_request_data();
let collection_id = format!("{}/users/{}/following", self.base_url, user_id);
let total = data.follow_repo.count_following(user_id).await?;
let obj = if let Some(p) = page {
let p = p.max(1);
let offset = (p.saturating_sub(1) as usize) * PAGE_SIZE;
let following = data
.follow_repo
.get_following_page(user_id, offset as u32, PAGE_SIZE)
.await?;
let has_next = offset + following.len() < total;
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
let mut obj = serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollectionPage","id":format!("{}?page={}",collection_id,p),"partOf":collection_id,"totalItems":total,"orderedItems":items});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
}
obj
} else {
serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollection","id":collection_id,"totalItems":total,"first":format!("{}?page=1",collection_id)})
};
Ok(serde_json::to_string(&obj)?)
}
pub async fn mark_follower_accepted(
&self,
user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo
.update_follower_status(
user_id,
actor_url,
crate::repository::FollowerStatus::Accepted,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
pub async fn mark_follower_rejected(
&self,
user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo
.remove_follower(user_id, actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
pub async fn lookup_actor_by_handle(
&self,
handle: &str,
) -> anyhow::Result<crate::user::LookedUpActor> {
tracing::info!(handle, "looking up remote actor");
let data = self.federation_config.to_request_data();
let actor = self
.webfinger_https(handle, &data)
.await
.inspect_err(|e| tracing::warn!(handle, error = %e, "actor lookup failed"))?;
let domain = actor.ap_id.host_str().unwrap_or("").to_string();
tracing::info!(handle = format!("{}@{}", actor.username, domain), ap_url = %actor.ap_id, "remote actor resolved");
Ok(crate::user::LookedUpActor {
handle: format!("{}@{}", actor.username, domain),
display_name: actor.display_name,
bio: actor.bio,
avatar_url: actor.avatar_url,
banner_url: actor.banner_url,
ap_url: actor.ap_id,
outbox_url: Some(actor.outbox_url),
followers_url: Some(actor.followers_url),
following_url: Some(actor.following_url),
also_known_as: actor.also_known_as,
profile_url: actor.profile_url,
attachment: actor.attachment,
})
}
pub async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.add_blocked_domain(domain, reason).await
}
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.remove_blocked_domain(domain).await
}
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.get_blocked_domains().await
}
// ── Private helpers (accessible to child modules via Rust's privacy rules) ─
async fn accepted_follower_inboxes(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Option<(DbActor, Vec<Url>)>> {
let local_actor = get_local_actor(local_user_id, data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, data).await?;
let inbox_strs = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
@@ -448,62 +108,12 @@ impl ActivityPubService {
if inbox_strs.is_empty() {
return Ok(None);
}
let inboxes: Vec<Url> = inbox_strs.into_iter().filter_map(|s| {
Url::parse(&s).map_err(|e| tracing::warn!(inbox = %s, error = %e, "skipping unparseable inbox URL")).ok()
let inboxes: Vec<Url> = inbox_strs.into_iter().filter_map(|inbox_str| {
Url::parse(&inbox_str).map_err(|e| tracing::warn!(inbox = %inbox_str, error = %e, "skipping unparseable inbox URL")).ok()
}).collect();
if inboxes.is_empty() {
return Ok(None);
}
Ok(Some((local_actor, inboxes)))
}
async fn webfinger_https(
&self,
handle: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<DbActor> {
let normalized = handle.trim_start_matches('@');
let at = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
let wf_url = format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str
);
tracing::debug!(handle, wf_url, "resolving webfinger");
let wf_parsed = Url::parse(&wf_url)?;
crate::security::validate_url(&wf_parsed).await?;
let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?;
let self_href = wf["links"]
.as_array()
.and_then(|links| {
links.iter().find(|l| {
l["rel"].as_str() == Some("self")
&& l["type"].as_str() == Some("application/activity+json")
})
})
.and_then(|l| l["href"].as_str())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
.to_owned();
tracing::debug!(handle, self_href, "webfinger resolved, fetching actor");
let actor: DbActor =
activitypub_federation::fetch::object_id::ObjectId::from(url::Url::parse(&self_href)?)
.dereference(data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(actor)
}
}
#[cfg(test)]
mod tests {
// Inbox deduplication and broadcast filtering are now tested via repository
// integration tests in the consuming crate. See get_accepted_follower_inboxes.
}

63
src/service/types.rs Normal file
View File

@@ -0,0 +1,63 @@
use url::Url;
use crate::user::ApVisibility;
pub(crate) struct Addressing {
pub to: Vec<String>,
pub cc: Vec<String>,
}
pub(crate) fn visibility_addressing(visibility: ApVisibility, followers_url: &Url) -> Addressing {
match visibility {
ApVisibility::Public => Addressing {
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
},
ApVisibility::FollowersOnly => Addressing {
to: vec![followers_url.to_string()],
cc: vec![],
},
ApVisibility::Private => Addressing {
to: vec![],
cc: vec![],
},
}
}
#[derive(serde::Serialize)]
pub(super) struct AnnounceRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
pub actor: String,
pub object: String,
}
#[derive(serde::Serialize)]
pub(super) struct LikeRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
pub actor: String,
pub object: String,
}
#[derive(serde::Serialize)]
pub(super) struct TombstoneRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
}
#[derive(serde::Serialize)]
pub(super) struct AddRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
pub object: AddRefObject,
}
#[derive(serde::Serialize)]
pub(super) struct AddRefObject {
pub id: String,
}