7 Commits

Author SHA1 Message Date
bc857b2c08 feat: signed actor lookup and display_name on DbActor
Add display_name field to DbActor, populated from AP Person.name in
from_json. Expose LookedUpActor type and lookup_actor_by_handle method
on ActivityPubService — uses the existing signed webfinger_https path
so strict instances (Threads, etc.) return full actor data.
2026-05-27 22:21:58 +02:00
7901b29f7c fix(actors): populate profile fields in read_from_id 2026-05-24 00:32:00 +02:00
Gabriel
a604e1bd40 docs: add README 2026-05-17 23:16:13 +02:00
Gabriel
f5374ec861 feat: add followers/following collection json methods 2026-05-17 22:58:30 +02:00
Gabriel
cc30582a1c feat: add broadcast_create_note, broadcast_update_note, base_url() accessor 2026-05-17 22:56:57 +02:00
Gabriel
f8dc20c026 gitignore 2026-05-17 22:54:03 +02:00
Gabriel
630cffe33f feat: k-ap public API, no ap_ports 2026-05-17 22:31:23 +02:00
6 changed files with 248 additions and 8 deletions

View File

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

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# 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

@@ -19,6 +19,7 @@ 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,
@@ -152,6 +153,7 @@ 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,
@@ -219,6 +221,7 @@ 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,
@@ -228,12 +231,12 @@ impl Object for DbActor {
following_url, following_url,
ap_id, ap_id,
last_refreshed_at: Utc::now(), last_refreshed_at: Utc::now(),
bio: None, bio: user.bio,
avatar_url: None, avatar_url: user.avatar_url,
banner_url: None, banner_url: user.banner_url,
also_known_as: None, also_known_as: user.also_known_as,
profile_url: None, profile_url: user.profile_url,
attachment: vec![], attachment: user.attachment,
})) }))
} }
@@ -327,6 +330,7 @@ impl Object for DbActor {
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}; pub use user::{ApProfileField, ApUser, ApUserRepository, LookedUpActor};

View File

@@ -224,6 +224,98 @@ 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)?;
@@ -238,6 +330,33 @@ impl ActivityPubService {
Ok(serde_json::to_string(&WithContext::new_default(person))?) Ok(serde_json::to_string(&WithContext::new_default(person))?)
} }
/// 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> {
let data = self.federation_config.to_request_data();
let actor = Self::webfinger_https(handle, &data).await?;
let domain = actor.ap_id.host_str().unwrap_or("").to_string();
let handle = format!("{}@{}", actor.username, domain);
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: actor.outbox_url,
followers_url: actor.followers_url,
following_url: 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.

View File

@@ -7,6 +7,24 @@ 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: Url,
pub followers_url: Url,
pub following_url: 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,