4 Commits

Author SHA1 Message Date
Gabriel
72cda57dd9 feat: add broadcast_create_note, broadcast_update_note, base_url() accessor 2026-05-17 22:56:57 +02:00
Gabriel
7927aec05e gitignore 2026-05-17 22:54:03 +02:00
Gabriel
1021861e2b clean up 2026-05-17 22:53:45 +02:00
Gabriel
fc01619a25 feat: k-ap public API, no ap_ports 2026-05-17 22:31:23 +02:00
9 changed files with 30 additions and 507 deletions

2
Cargo.lock generated
View File

@@ -1368,7 +1368,7 @@ dependencies = [
[[package]] [[package]]
name = "k-ap" name = "k-ap"
version = "0.1.9" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "k-ap" name = "k-ap"
version = "0.1.10" version = "0.1.0"
edition = "2024" edition = "2024"
description = "Generic ActivityPub protocol layer" description = "Generic ActivityPub protocol layer"
license = "MIT" license = "MIT"

View File

@@ -1,99 +0,0 @@
# k-ap
Generic ActivityPub protocol layer for Rust services. Extracted from the `thoughts` and `movies-diary` projects.
Wraps [`activitypub_federation`](https://crates.io/crates/activitypub_federation) and provides the plumbing that every AP-enabled service needs: actor management, inbox/outbox routing, follower tracking, WebFinger, NodeInfo, and HTTP signature handling.
Not domain-specific — no opinions about what your content type looks like.
## Add as dependency
```toml
[dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.0" }
```
## What you implement
Three traits wire your data layer into `k-ap`:
```rust
// Your database layer for follows, keypairs, remote actors, blocks
impl FederationRepository for MyFederationRepo { ... }
// Your user lookup (id, username, bio, avatar)
impl ApUserRepository for MyUserRepo { ... }
// Dispatch incoming AP objects to the right handler
impl ApObjectHandler for MyObjectHandler { ... }
```
## Wire up the service
```rust
use k_ap::{ActivityPubService, FederationRepository, ApUserRepository, ApObjectHandler};
let service = ActivityPubService::builder(
Arc::new(my_federation_repo),
Arc::new(my_user_repo),
Arc::new(my_object_handler),
"https://example.com",
)
.allow_registration(true)
.software_name("my-app")
.build()
.await?;
// Mount the AP routes onto your axum router
let router = Router::new().merge(service.router());
```
## What the service handles for you
- **Actor** — `GET /users/:id` serves the AP Person object with public key
- **Inbox** — `POST /users/:id/inbox` + `POST /inbox` (shared), verifies HTTP signatures, dispatches to your `ApObjectHandler`
- **Outbox** — `GET /users/:id/outbox` with OrderedCollection pagination
- **Followers / Following** — `GET /users/:id/followers` and `/following`
- **WebFinger** — `GET /.well-known/webfinger`
- **NodeInfo** — `GET /.well-known/nodeinfo` + `GET /nodeinfo/2.1`
## Broadcast from your domain layer
```rust
// Fan out a new note to all accepted followers
service.broadcast_create_note(user_id, &note_json).await?;
service.broadcast_update_note(user_id, &note_json).await?;
// Announce / Undo Announce
service.broadcast_announce_to_followers(user_id, object_ap_id).await?;
service.broadcast_undo_announce_to_followers(user_id, object_ap_id, object_url).await?;
// Like / Unlike to a remote inbox
service.broadcast_like_to_inbox(user_id, object_ap_id, inbox_url).await?;
service.broadcast_undo_like_to_inbox(user_id, object_ap_id, inbox_url).await?;
// Follow / Unfollow / Accept / Reject
service.follow(local_user_id, remote_actor_url, handle).await?;
service.unfollow(local_user_id, remote_actor_url).await?;
service.accept_follower(local_user_id, remote_actor_url).await?;
service.reject_follower(local_user_id, remote_actor_url).await?;
```
## Project-specific ports
`k-ap` does not define port traits tied to your domain (e.g. `OutboundFederationPort`, `ActivityPubRepository<Thought>`). Those belong in your adapter layer and are wired up there. See `crates/adapters/activitypub/src/port.rs` in `thoughts` for a reference implementation.
## Key public types
| Type | Description |
|------|-------------|
| `ActivityPubService` | Central service — build once, share via `Arc` |
| `FederationData` | Request-scoped data passed through the federation layer |
| `FederationRepository` | Trait: follows, keypairs, remote actors, blocks |
| `ApUserRepository` | Trait: user lookup by id / username |
| `ApObjectHandler` | Trait: dispatch incoming AP objects |
| `RemoteActor` | A federated actor record |
| `Follower` / `FollowerStatus` | Follower with pending/accepted/rejected state |
| `ApUser` | AP-serializable local user |
| `ApFederationConfig` | Wraps the `activitypub_federation` config |
| `Error` | AP-layer error type |

View File

@@ -65,20 +65,12 @@ impl Activity for FollowActivity {
))); )));
} }
}; };
if target_domain == data.domain { if target_domain != data.domain {
return Ok(()); return Err(Error::bad_request(anyhow::anyhow!(
}
// Domain mismatch — still accept if the UUID resolves to a local user.
// This handles domain migrations where remote servers have cached the old actor URL.
if let Some(uuid) = crate::urls::extract_user_id_from_url(target_url) {
if data.user_repo.find_by_id(uuid).await.ok().flatten().is_some() {
tracing::debug!(target = %target_url, local_domain = %data.domain, "accepting follow for migrated actor URL");
return Ok(());
}
}
Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor" "follow target is not a local actor"
))) )));
}
Ok(())
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
@@ -837,86 +829,10 @@ impl Activity for MoveActivity {
if data.federation_repo.is_domain_blocked(domain).await? { if data.federation_repo.is_domain_blocked(domain).await? {
return Ok(()); return Ok(());
} }
// Fetch the target actor via signed request.
let target = ObjectId::<DbActor>::from(self.target.clone())
.dereference(data)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{e}")))?;
// Verify the new actor claims the old identity via alsoKnownAs.
let old_url = self.object.as_str();
if target.also_known_as.as_deref() != Some(old_url) {
return Err(Error::bad_request(anyhow::anyhow!(
"Move target alsoKnownAs does not reference old actor"
)));
}
// Migrate DB records; get user IDs that need a re-follow.
let affected = data
.federation_repo
.migrate_follower_actor(old_url, self.target.as_str())
.await
.map_err(|e| Error::from(anyhow::anyhow!("{e}")))?;
let affected_count = affected.len();
// Re-follow on behalf of each affected local user.
for local_user_id in &affected {
let local_actor = match crate::actors::get_local_actor(*local_user_id, data).await {
Ok(a) => a,
Err(e) => {
tracing::warn!(error = %e, %local_user_id, "Move: failed to load local actor for re-follow");
continue;
}
};
let follow_id = match crate::urls::activity_url(&data.base_url) {
Ok(u) => u,
Err(e) => {
tracing::warn!(error = %e, "Move: failed to generate follow activity URL");
continue;
}
};
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: activitypub_federation::fetch::object_id::ObjectId::from(
local_actor.ap_id.clone(),
),
object: activitypub_federation::fetch::object_id::ObjectId::from(
self.target.clone(),
),
};
let sends = match activitypub_federation::activity_sending::SendActivityTask::prepare(
&activitypub_federation::protocol::context::WithContext::new_default(follow),
&local_actor,
vec![target.inbox_url.clone()],
data,
)
.await
{
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "Move: failed to prepare re-follow");
continue;
}
};
for send in sends {
if let Err(e) = send.sign_and_send(data).await {
tracing::warn!(error = %e, %local_user_id, "Move: re-follow delivery failed");
}
}
}
tracing::info!( tracing::info!(
actor = %self.actor.inner(), actor = %self.actor.inner(),
target = %self.target, target = %self.target,
affected = affected_count, "received Move (account migration) — target noted"
"received Move — migrated follower relationships"
); );
Ok(()) Ok(())
} }

View File

@@ -2,6 +2,7 @@ use activitypub_federation::{
config::Data, config::Data,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair, http_signatures::generate_actor_keypair,
kinds::actor::PersonType,
protocol::{public_key::PublicKey, verification::verify_domains_match}, protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::{Actor, Object}, traits::{Actor, Object},
}; };
@@ -18,7 +19,6 @@ use crate::user::ApProfileField;
pub struct DbActor { pub struct DbActor {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub username: String, pub username: String,
pub display_name: Option<String>,
pub public_key_pem: String, pub public_key_pem: String,
pub private_key_pem: Option<String>, pub private_key_pem: Option<String>,
pub inbox_url: Url, pub inbox_url: Url,
@@ -57,39 +57,18 @@ pub struct ProfileFieldObject {
pub value: String, pub value: String,
} }
/// Accepts any AP actor type on inbound JSON; always serializes as "Person" for local actors.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ApActorType {
Person,
Service,
Application,
Organization,
Group,
}
impl Default for ApActorType {
fn default() -> Self {
Self::Person
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Person { pub struct Person {
#[serde(rename = "type")] #[serde(rename = "type")]
kind: ApActorType, kind: PersonType,
id: ObjectId<DbActor>, id: ObjectId<DbActor>,
#[serde(default)]
preferred_username: String, preferred_username: String,
inbox: Url, inbox: Url,
#[serde(default)] outbox: Url,
outbox: Option<Url>, followers: Url,
#[serde(default)] following: Url,
followers: Option<Url>, public_key: PublicKey,
#[serde(default)]
following: Option<Url>,
pub public_key: PublicKey,
#[serde(default)]
name: Option<String>, name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>, summary: Option<String>,
@@ -99,7 +78,6 @@ pub struct Person {
url: Option<Url>, url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>, discoverable: Option<bool>,
#[serde(default)]
manually_approves_followers: bool, manually_approves_followers: bool,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
updated: Option<DateTime<Utc>>, updated: Option<DateTime<Utc>>,
@@ -174,7 +152,6 @@ pub async fn get_local_actor(
Ok(DbActor { Ok(DbActor {
user_id, user_id,
username: user.username, username: user.username,
display_name: None,
public_key_pem: public_key, public_key_pem: public_key,
private_key_pem: Some(private_key), private_key_pem: Some(private_key),
inbox_url, inbox_url,
@@ -193,11 +170,6 @@ pub async fn get_local_actor(
}) })
} }
fn apex_domain(url: &Url) -> String {
let host = url.host_str().unwrap_or("");
host.strip_prefix("www.").unwrap_or(host).to_owned()
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl Object for DbActor { impl Object for DbActor {
type DataType = FederationData; type DataType = FederationData;
@@ -247,7 +219,6 @@ impl Object for DbActor {
Ok(Some(DbActor { Ok(Some(DbActor {
user_id, user_id,
username: user.username, username: user.username,
display_name: None,
public_key_pem: public_key, public_key_pem: public_key,
private_key_pem: private_key, private_key_pem: private_key,
inbox_url, inbox_url,
@@ -257,12 +228,12 @@ impl Object for DbActor {
following_url, following_url,
ap_id, ap_id,
last_refreshed_at: Utc::now(), last_refreshed_at: Utc::now(),
bio: user.bio, bio: None,
avatar_url: user.avatar_url, avatar_url: None,
banner_url: user.banner_url, banner_url: None,
also_known_as: user.also_known_as, also_known_as: None,
profile_url: user.profile_url, profile_url: None,
attachment: user.attachment, attachment: vec![],
})) }))
} }
@@ -301,9 +272,9 @@ impl Object for DbActor {
id: self.ap_id.clone().into(), id: self.ap_id.clone().into(),
preferred_username: self.username.clone(), preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(), inbox: self.inbox_url.clone(),
outbox: Some(self.outbox_url.clone()), outbox: self.outbox_url.clone(),
followers: Some(self.followers_url.clone()), followers: self.followers_url.clone(),
following: Some(self.following_url.clone()), following: self.following_url.clone(),
public_key, public_key,
name: Some(self.username.clone()), name: Some(self.username.clone()),
summary: self.bio.clone(), summary: self.bio.clone(),
@@ -324,26 +295,11 @@ impl Object for DbActor {
expected_domain: &Url, expected_domain: &Url,
_data: &Data<Self::DataType>, _data: &Data<Self::DataType>,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
if verify_domains_match(json.id.inner(), expected_domain).is_ok() { verify_domains_match(json.id.inner(), expected_domain)?;
return Ok(()); Ok(())
}
if apex_domain(json.id.inner()) == apex_domain(expected_domain) {
tracing::debug!(
actor_id = %json.id.inner(),
expected = %expected_domain,
"domain verified via www-apex equivalence"
);
return Ok(());
}
verify_domains_match(json.id.inner(), expected_domain).map_err(Error::from)
} }
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> { async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
tracing::debug!(
actor_id = %json.id.inner(),
username = %json.preferred_username,
"ingesting remote actor"
);
let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string()); let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string());
let actor = RemoteActor { let actor = RemoteActor {
url: json.id.inner().to_string(), url: json.id.inner().to_string(),
@@ -352,7 +308,7 @@ impl Object for DbActor {
shared_inbox_url, shared_inbox_url,
display_name: json.name.clone(), display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()), avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
outbox_url: json.outbox.as_ref().map(|u| u.to_string()), outbox_url: Some(json.outbox.to_string()),
}; };
data.federation_repo.upsert_remote_actor(actor).await?; data.federation_repo.upsert_remote_actor(actor).await?;
@@ -364,17 +320,13 @@ impl Object for DbActor {
.endpoints .endpoints
.as_ref() .as_ref()
.and_then(|e| Url::parse(e.shared_inbox.as_str()).ok()); .and_then(|e| Url::parse(e.shared_inbox.as_str()).ok());
let fallback = |suffix: &str| { let outbox_url = json.outbox.clone();
Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.clone()) let followers_url = json.followers.clone();
}; let following_url = json.following.clone();
let outbox_url = json.outbox.clone().unwrap_or_else(|| fallback("/outbox"));
let followers_url = json.followers.clone().unwrap_or_else(|| fallback("/followers"));
let following_url = json.following.clone().unwrap_or_else(|| fallback("/following"));
Ok(DbActor { Ok(DbActor {
user_id, user_id,
username: json.preferred_username.clone(), username: json.preferred_username.clone(),
display_name: json.name.clone(),
public_key_pem: json.public_key.public_key_pem, public_key_pem: json.public_key.public_key_pem,
private_key_pem: None, private_key_pem: None,
inbox_url, inbox_url,

View File

@@ -25,4 +25,4 @@ pub use repository::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
pub use service::ActivityPubService; pub use service::ActivityPubService;
pub use user::{ApProfileField, ApUser, ApUserRepository, LookedUpActor}; pub use user::{ApProfileField, ApUser, ApUserRepository};

View File

@@ -131,12 +131,4 @@ pub trait FederationRepository: Send + Sync {
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>; async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>; async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>; async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
/// Migrate all local following records from old_actor_url to new_actor_url.
/// Returns the local user IDs whose records were migrated (excludes users
/// already following the new actor — they need no re-follow).
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>>;
} }

View File

@@ -224,98 +224,6 @@ impl ActivityPubService {
Ok(Some((local_actor, collect_inboxes(&accepted)))) Ok(Some((local_actor, collect_inboxes(&accepted))))
} }
/// Build an OrderedCollection or OrderedCollectionPage JSON for the local
/// user's followers list. Pass `page = None` for the root collection.
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.federation_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
.federation_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)?)
}
/// Build an OrderedCollection or OrderedCollectionPage JSON for the local
/// user's following list. Pass `page = None` for the root collection.
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.federation_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
.federation_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 actor_json(&self, user_id_str: &str) -> anyhow::Result<String> { pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
use activitypub_federation::traits::Object; use activitypub_federation::traits::Object;
let uuid = uuid::Uuid::parse_str(user_id_str)?; let uuid = uuid::Uuid::parse_str(user_id_str)?;
@@ -330,64 +238,6 @@ impl ActivityPubService {
Ok(serde_json::to_string(&WithContext::new_default(person))?) Ok(serde_json::to_string(&WithContext::new_default(person))?)
} }
/// Mark a remote follower as accepted in the DB only — no AP activity is sent.
/// The caller is responsible for delivering the Accept activity separately.
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.federation_repo
.update_follower_status(user_id, actor_url, crate::repository::FollowerStatus::Accepted)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
/// Remove a remote follower from the DB only — no AP activity is sent.
/// The caller is responsible for delivering the Reject activity separately.
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.federation_repo
.remove_follower(user_id, actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
/// Resolve a `@user@domain` handle to actor data using a signed HTTP request.
/// Unlike a plain unauthenticated fetch, this works with instances (e.g. Threads)
/// that require HTTP signatures before returning full actor JSON.
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();
let handle = format!("{}@{}", actor.username, domain);
tracing::info!(handle, ap_url = %actor.ap_id, "remote actor resolved");
Ok(crate::user::LookedUpActor {
handle,
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,
})
}
/// Returns the ActivityPub router compatible with any outer state `S`. /// Returns the ActivityPub router compatible with any outer state `S`.
/// Handlers only use `Data<FederationData>` injected by the middleware layer, /// Handlers only use `Data<FederationData>` injected by the middleware layer,
/// so the router is independent of the application state type. /// so the router is independent of the application state type.
@@ -628,7 +478,6 @@ impl ActivityPubService {
"https://{}/.well-known/webfinger?resource=acct:{}@{}", "https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str domain_str, user, domain_str
); );
tracing::debug!(handle, wf_url, "resolving webfinger");
let wf: serde_json::Value = reqwest::Client::new() let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url) .get(&wf_url)
.header("Accept", "application/jrd+json, application/json") .header("Accept", "application/jrd+json, application/json")
@@ -647,7 +496,6 @@ impl ActivityPubService {
.and_then(|l| l["href"].as_str()) .and_then(|l| l["href"].as_str())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))? .ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
.to_owned(); .to_owned();
tracing::debug!(handle, self_href, "webfinger resolved, fetching actor with signature");
let self_url = url::Url::parse(&self_href)?; let self_url = url::Url::parse(&self_href)?;
let actor: DbActor = ObjectId::from(self_url) let actor: DbActor = ObjectId::from(self_url)
.dereference(data) .dereference(data)
@@ -1231,74 +1079,6 @@ impl ActivityPubService {
Ok(()) Ok(())
} }
/// Broadcast a Move activity to all accepted followers, signalling that this
/// actor is migrating to `new_actor_url`.
///
/// **Pre-condition (caller's responsibility):**
/// Before calling this, the application must persist `also_known_as = [new_actor_url]`
/// in the local actor's row so the old actor JSON already advertises the new URL
/// when remote servers fetch it to verify the cross-reference.
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 followers = data.federation_repo.get_followers(user_id).await?;
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.collect();
if accepted.is_empty() {
tracing::info!(
%user_id,
"broadcast_move: no accepted followers, nothing to send"
);
return Ok(());
}
let inboxes = collect_inboxes(&accepted);
let move_id =
crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let move_activity = crate::activities::MoveActivity {
id: move_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: local_actor.ap_id.clone(),
target: new_actor_url.clone(),
};
let sends = SendActivityTask::prepare(
&WithContext::new_default(move_activity),
&local_actor,
inboxes,
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
count = failures.len(),
"some Move deliveries failed permanently"
);
}
tracing::info!(
%user_id,
target = %new_actor_url,
"broadcast_move: delivered to all accepted followers"
);
Ok(())
}
pub async fn block_actor( pub async fn block_actor(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,

View File

@@ -7,24 +7,6 @@ pub struct ApProfileField {
pub value: String, pub value: String,
} }
/// Resolved actor data returned by [`crate::service::ActivityPubService::lookup_actor_by_handle`].
/// Fetched via a signed HTTP request so strict instances (e.g. Threads) return full data.
#[derive(Debug, Clone)]
pub struct LookedUpActor {
pub handle: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub ap_url: Url,
pub outbox_url: Option<Url>,
pub followers_url: Option<Url>,
pub following_url: Option<Url>,
pub also_known_as: Option<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ApUser { pub struct ApUser {
pub id: uuid::Uuid, pub id: uuid::Uuid,