Compare commits
5 Commits
v0.1.8
...
b557bd9d46
| Author | SHA1 | Date | |
|---|---|---|---|
| b557bd9d46 | |||
| d80cfd0431 | |||
| 432f39cbb4 | |||
| 2c509cbf88 | |||
| 52614d406a |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1368,7 +1368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-ap"
|
||||
version = "0.1.7"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"activitypub_federation",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "k-ap"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
edition = "2024"
|
||||
description = "Generic ActivityPub protocol layer"
|
||||
license = "MIT"
|
||||
|
||||
56
README.md
56
README.md
@@ -10,7 +10,7 @@ Not domain-specific — no opinions about what your content type looks like.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.0" }
|
||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
||||
```
|
||||
|
||||
## What you implement
|
||||
@@ -21,7 +21,7 @@ Three traits wire your data layer into `k-ap`:
|
||||
// Your database layer for follows, keypairs, remote actors, blocks
|
||||
impl FederationRepository for MyFederationRepo { ... }
|
||||
|
||||
// Your user lookup (id, username, bio, avatar)
|
||||
// Your user lookup (id, username, bio, avatar, alsoKnownAs)
|
||||
impl ApUserRepository for MyUserRepo { ... }
|
||||
|
||||
// Dispatch incoming AP objects to the right handler
|
||||
@@ -55,28 +55,55 @@ let router = Router::new().merge(service.router());
|
||||
- **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`
|
||||
- **NodeInfo** — `GET /.well-known/nodeinfo` + `GET /nodeinfo/2.0`
|
||||
|
||||
## Broadcast from your domain layer
|
||||
|
||||
```rust
|
||||
// Fan out a new note to all accepted followers
|
||||
service.broadcast_create_note(user_id, ¬e_json).await?;
|
||||
service.broadcast_update_note(user_id, ¬e_json).await?;
|
||||
service.broadcast_create_note(user_id, note_json).await?;
|
||||
service.broadcast_update_note(user_id, note_json).await?;
|
||||
service.broadcast_delete_to_followers(user_id, ap_id).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?;
|
||||
service.broadcast_undo_announce_to_followers(user_id, object_ap_id).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?;
|
||||
// Actor profile update
|
||||
service.broadcast_actor_update(user_id).await?;
|
||||
|
||||
// Account migration — sends a Move activity to all followers
|
||||
// Pre-condition: set alsoKnownAs on the local actor before calling this
|
||||
service.broadcast_move(user_id, new_actor_url).await?;
|
||||
```
|
||||
|
||||
## Follow management
|
||||
|
||||
```rust
|
||||
// Outbound follows (resolves handle via WebFinger)
|
||||
service.follow(local_user_id, "@user@remote.example").await?;
|
||||
service.unfollow(local_user_id, remote_actor_url).await?;
|
||||
|
||||
// Inbound follow requests — full flow (DB update + AP delivery + backfill)
|
||||
service.accept_follower(local_user_id, remote_actor_url).await?;
|
||||
service.reject_follower(local_user_id, remote_actor_url).await?;
|
||||
|
||||
// Inbound follow requests — DB only (no AP delivery)
|
||||
// Use these when delivering Accept/Reject from a separate worker process
|
||||
service.mark_follower_accepted(local_user_id, remote_actor_url).await?;
|
||||
service.mark_follower_rejected(local_user_id, remote_actor_url).await?;
|
||||
```
|
||||
|
||||
## Actor lookup
|
||||
|
||||
```rust
|
||||
// Resolve a handle via WebFinger using a signed HTTP request.
|
||||
// Works with strict instances (e.g. Threads) that require HTTP signatures.
|
||||
let actor: LookedUpActor = service.lookup_actor_by_handle("@user@remote.example").await?;
|
||||
```
|
||||
|
||||
## Project-specific ports
|
||||
@@ -92,8 +119,19 @@ service.reject_follower(local_user_id, remote_actor_url).await?;
|
||||
| `FederationRepository` | Trait: follows, keypairs, remote actors, blocks |
|
||||
| `ApUserRepository` | Trait: user lookup by id / username |
|
||||
| `ApObjectHandler` | Trait: dispatch incoming AP objects |
|
||||
| `LookedUpActor` | Resolved remote actor data from `lookup_actor_by_handle` |
|
||||
| `RemoteActor` | A federated actor record |
|
||||
| `Follower` / `FollowerStatus` | Follower with pending/accepted/rejected state |
|
||||
| `ApUser` | AP-serializable local user |
|
||||
| `ApUser` | AP-serializable local user (includes `also_known_as`) |
|
||||
| `ApFederationConfig` | Wraps the `activitypub_federation` config |
|
||||
| `Error` | AP-layer error type |
|
||||
|
||||
## Inbound activity handling
|
||||
|
||||
The library handles the following inbound AP activities out of the box:
|
||||
|
||||
`Follow`, `Accept`, `Reject`, `Undo` (Follow, Like, Announce), `Create`, `Update`, `Delete`, `Announce`, `Like`, `Add`, `Block`, `Move`
|
||||
|
||||
`Move` is fully handled: verifies `alsoKnownAs` cross-reference on the target actor, migrates all local following records, and re-follows the new actor on behalf of affected users.
|
||||
|
||||
Actor types accepted: `Person`, `Service`, `Application`, `Organization`, `Group`.
|
||||
|
||||
@@ -837,10 +837,86 @@ impl Activity for MoveActivity {
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
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!(
|
||||
actor = %self.actor.inner(),
|
||||
target = %self.target,
|
||||
"received Move (account migration) — target noted"
|
||||
affected = affected_count,
|
||||
"received Move — migrated follower relationships"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -131,4 +131,12 @@ pub trait FederationRepository: Send + Sync {
|
||||
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 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>>;
|
||||
}
|
||||
|
||||
@@ -330,6 +330,34 @@ impl ActivityPubService {
|
||||
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.
|
||||
@@ -1203,6 +1231,74 @@ impl ActivityPubService {
|
||||
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(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
|
||||
Reference in New Issue
Block a user