Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 699258f830 | |||
| 9412a9739a | |||
| 13111c10b9 | |||
| 2e3b6d5cd4 | |||
| bc857b2c08 | |||
| 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
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.1.0"
|
version = "0.1.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Generic ActivityPub protocol layer"
|
description = "Generic ActivityPub protocol layer"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
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 |
|
||||||
@@ -2,7 +2,6 @@ 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},
|
||||||
};
|
};
|
||||||
@@ -19,6 +18,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,
|
||||||
@@ -57,18 +57,39 @@ 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: PersonType,
|
kind: ApActorType,
|
||||||
id: ObjectId<DbActor>,
|
id: ObjectId<DbActor>,
|
||||||
|
#[serde(default)]
|
||||||
preferred_username: String,
|
preferred_username: String,
|
||||||
inbox: Url,
|
inbox: Url,
|
||||||
outbox: Url,
|
#[serde(default)]
|
||||||
followers: Url,
|
outbox: Option<Url>,
|
||||||
following: Url,
|
#[serde(default)]
|
||||||
public_key: PublicKey,
|
followers: Option<Url>,
|
||||||
|
#[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>,
|
||||||
@@ -78,6 +99,7 @@ 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>>,
|
||||||
@@ -152,6 +174,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,
|
||||||
@@ -170,6 +193,11 @@ 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;
|
||||||
@@ -219,6 +247,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 +257,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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,9 +301,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: self.outbox_url.clone(),
|
outbox: Some(self.outbox_url.clone()),
|
||||||
followers: self.followers_url.clone(),
|
followers: Some(self.followers_url.clone()),
|
||||||
following: self.following_url.clone(),
|
following: Some(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(),
|
||||||
@@ -295,11 +324,26 @@ impl Object for DbActor {
|
|||||||
expected_domain: &Url,
|
expected_domain: &Url,
|
||||||
_data: &Data<Self::DataType>,
|
_data: &Data<Self::DataType>,
|
||||||
) -> Result<(), Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
verify_domains_match(json.id.inner(), expected_domain)?;
|
if verify_domains_match(json.id.inner(), expected_domain).is_ok() {
|
||||||
Ok(())
|
return 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(),
|
||||||
@@ -308,7 +352,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: Some(json.outbox.to_string()),
|
outbox_url: json.outbox.as_ref().map(|u| u.to_string()),
|
||||||
};
|
};
|
||||||
data.federation_repo.upsert_remote_actor(actor).await?;
|
data.federation_repo.upsert_remote_actor(actor).await?;
|
||||||
|
|
||||||
@@ -320,13 +364,17 @@ 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 outbox_url = json.outbox.clone();
|
let fallback = |suffix: &str| {
|
||||||
let followers_url = json.followers.clone();
|
Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.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,
|
||||||
|
|||||||
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, LookedUpActor};
|
||||||
|
|||||||
215
src/service.rs
215
src/service.rs
@@ -32,6 +32,7 @@ const DELIVERY_INITIAL_DELAY_SECS: u64 = 1;
|
|||||||
const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
|
const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
|
||||||
const BATCH_FETCH_SLEEP_MS: u64 = 100;
|
const BATCH_FETCH_SLEEP_MS: u64 = 100;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn content_to_html(text: &str) -> String {
|
fn content_to_html(text: &str) -> String {
|
||||||
let escaped = text
|
let escaped = text
|
||||||
.replace('&', "&")
|
.replace('&', "&")
|
||||||
@@ -172,6 +173,10 @@ impl ActivityPubService {
|
|||||||
self.federation_config.to_request_data()
|
self.federation_config.to_request_data()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> &str {
|
||||||
|
&self.base_url
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers,
|
/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers,
|
||||||
/// excluding blocked actors and blocked domains.
|
/// excluding blocked actors and blocked domains.
|
||||||
/// Returns `None` if there are no eligible followers.
|
/// Returns `None` if there are no eligible followers.
|
||||||
@@ -219,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)?;
|
||||||
@@ -233,6 +330,36 @@ 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> {
|
||||||
|
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.
|
||||||
@@ -473,6 +600,7 @@ 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")
|
||||||
@@ -491,6 +619,7 @@ 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)
|
||||||
@@ -913,6 +1042,92 @@ impl ActivityPubService {
|
|||||||
Ok(())
|
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<()> {
|
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
|
||||||
use activitypub_federation::traits::Object;
|
use activitypub_federation::traits::Object;
|
||||||
|
|
||||||
|
|||||||
18
src/user.rs
18
src/user.rs
@@ -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: 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user