Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7901b29f7c | |||
|
|
a604e1bd40 | ||
|
|
f5374ec861 | ||
|
|
cc30582a1c | ||
|
|
f8dc20c026 | ||
|
|
630cffe33f |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
3271
Cargo.lock
generated
Normal file
3271
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
README.md
Normal file
99
README.md
Normal 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, ¬e_json).await?;
|
||||
service.broadcast_update_note(user_id, ¬e_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 |
|
||||
@@ -228,12 +228,12 @@ impl Object for DbActor {
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
profile_url: None,
|
||||
attachment: vec![],
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
also_known_as: user.also_known_as,
|
||||
profile_url: user.profile_url,
|
||||
attachment: user.attachment,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
29
src/lib.rs
29
src/lib.rs
@@ -1 +1,28 @@
|
||||
// placeholder — filled in Task 4
|
||||
pub mod activities;
|
||||
pub mod actor_handler;
|
||||
pub mod actors;
|
||||
pub mod content;
|
||||
pub mod data;
|
||||
pub mod error;
|
||||
pub mod federation;
|
||||
pub mod followers_handler;
|
||||
pub mod inbox;
|
||||
pub mod nodeinfo;
|
||||
pub mod outbox;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub(crate) mod urls;
|
||||
pub mod user;
|
||||
pub mod webfinger;
|
||||
|
||||
pub use urls::AS_PUBLIC;
|
||||
pub use activitypub_federation::kinds::object::NoteType;
|
||||
pub use content::ApObjectHandler;
|
||||
pub use data::FederationData;
|
||||
pub use error::Error;
|
||||
pub use federation::ApFederationConfig;
|
||||
pub use repository::{
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
pub use service::ActivityPubService;
|
||||
pub use user::{ApProfileField, ApUser, ApUserRepository};
|
||||
|
||||
183
src/service.rs
183
src/service.rs
@@ -32,6 +32,7 @@ const DELIVERY_INITIAL_DELAY_SECS: u64 = 1;
|
||||
const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
|
||||
const BATCH_FETCH_SLEEP_MS: u64 = 100;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn content_to_html(text: &str) -> String {
|
||||
let escaped = text
|
||||
.replace('&', "&")
|
||||
@@ -172,6 +173,10 @@ impl ActivityPubService {
|
||||
self.federation_config.to_request_data()
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers,
|
||||
/// excluding blocked actors and blocked domains.
|
||||
/// Returns `None` if there are no eligible followers.
|
||||
@@ -219,6 +224,98 @@ impl ActivityPubService {
|
||||
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> {
|
||||
use activitypub_federation::traits::Object;
|
||||
let uuid = uuid::Uuid::parse_str(user_id_str)?;
|
||||
@@ -913,6 +1010,92 @@ impl ActivityPubService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fan out a Create(Note) activity to all accepted followers.
|
||||
/// `note` is the fully-formed Note JSON (including id, type, content, etc.).
|
||||
/// The activity ID is derived deterministically from the note's `id` field.
|
||||
pub async fn broadcast_create_note(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
note: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let Some((local_actor, inboxes)) =
|
||||
self.accepted_follower_inboxes(&data, local_user_id).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let note_id_str = note["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 create = crate::activities::CreateActivity {
|
||||
id: create_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: note,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![local_actor.followers_url.to_string()],
|
||||
bto: vec![],
|
||||
bcc: vec![],
|
||||
};
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(create),
|
||||
&local_actor,
|
||||
inboxes,
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "some Create(Note) deliveries failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fan out an Update(Note) activity to all accepted followers.
|
||||
/// `note` is the fully-formed Note JSON.
|
||||
pub async fn broadcast_update_note(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
note: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let Some((local_actor, inboxes)) =
|
||||
self.accepted_follower_inboxes(&data, local_user_id).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let update_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let update = crate::activities::UpdateActivity {
|
||||
id: update_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: note,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![local_actor.followers_url.to_string()],
|
||||
};
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(update),
|
||||
&local_actor,
|
||||
inboxes,
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "some Update(Note) deliveries failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
|
||||
use activitypub_federation::traits::Object;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user