v0.5.0 — codebase refinement, flexible API, architecture cleanup
All checks were successful
CI / fmt (push) Successful in 31s
CI / clippy (push) Successful in 4m0s
CI / test (push) Successful in 5m44s

Error handling:
  thiserror enum (NotFound/BadRequest/Unauthorized/Forbidden/Internal)
  eliminates 41 boilerplate .map_err() calls
  signature failures return 401, not 500

Named types:
  Keypair, LocalObject (with to/cc/bto/bcc addressing), Addressing

Readability:
  descriptive names everywhere, small functions, breathing room
  noisy comments removed, intent-explicit error handling (no let _ =)
  types.rs per module separating data from behavior

File organization:
  handlers/ module (actor, featured, followers, inbox, nodeinfo, outbox, webfinger)
  actors/ split (mod.rs + person.rs + types.rs)
  service/ split (builder, broadcast, collections, delivery, fetch, follow, lookup, types)
  tests next to modules

Repository traits:
  FollowRepository → 5 sub-traits (FollowerWriter/Reader, FollowingWriter/Reader, FollowMigration)
  ActorRepository → 3 sub-traits (KeypairRepository, RemoteActorCache, AnnounceRepository)
  BlocklistRepository → 2 sub-traits (DomainBlocklist, ActorBlocklist)
  supertraits with blanket impls — existing consumers unchanged
  FollowMigration has default no-op
  delete dead get_following_outbox_url

Testing:
  mock_repo! macro generates mock builders from compact specs
  MockFollowRepo, MockActorRepo, MockBlocklistRepo, MockActivityRepo,
  MockUserRepo, MockContentReader, MockObjectHandler, MockEventPublisher
  all hand-written test stubs replaced

Flexibility:
  UrlScheme trait — configurable URL patterns (DefaultUrlScheme = /users/{uuid})
  on_unknown_activity hook for custom AP extensions
  broadcast_raw_to_followers for arbitrary activity JSON
  broadcast_create/broadcast_update (renamed from Note-centric names)
  internal modules locked to pub(crate), clean public re-exports
  actor_handler, followers_handler, following_handler re-exported for custom routers

Security:
  SSRF: block IPv6-mapped private IPv4, TEST-NET, benchmarking, reserved ranges
  verify_attributed_to rejects missing/array attributedTo
  remove .expect() from outbox handler

Architecture:
  handlers/followers.rs delegates to serialize_ordered_collection (no more UrlScheme bypass)
  extract dispatch_sends, prepare_addressed_broadcast (eliminate duplication)
  DbActor::object_id(), RemoteActor::from/from_ap_person/placeholder
  send_activity unifies prepare+dispatch, deterministic_activity_id helper
  pass-through wrappers grouped in lookup.rs
This commit is contained in:
2026-07-25 17:01:44 +02:00
parent 17b57fb9b5
commit b569efe715
73 changed files with 3583 additions and 3160 deletions

View File

@@ -1,5 +1,51 @@
# Changelog # Changelog
## [0.5.0] — 2026-07-25
### Breaking changes
- `broadcast_create_note` renamed to `broadcast_create`, `broadcast_update_note` renamed to `broadcast_update`
- `LocalObject` is now a named struct with addressing fields: `to`, `cc`, `bto`, `bcc`
- `ApContentReader::get_local_objects_page` returns `Vec<LocalObject>` instead of `Vec<(Url, Value, DateTime)>`
- `Error` is now a thiserror enum with `NotFound` / `BadRequest` / `Unauthorized` / `Forbidden` / `Internal` variants
- Repository traits split into sub-traits — `FollowRepository` is now a supertrait of `FollowerWriter`, `FollowerReader`, `FollowingWriter`, `FollowingReader`, `FollowMigration`; `ActorRepository` is a supertrait of `KeypairRepository`, `RemoteActorCache`, `AnnounceRepository`. Existing consumers implementing the supertrait continue to work unchanged.
- `ActorRepository::save_local_actor_keypair` now takes a `Keypair` named struct instead of two strings
- `NoteType` re-export removed
- Internal modules (`activities`, `actors`, `handlers`, `data`, `content`, `error`, `user`, `federation`) are now `pub(crate)`
- `get_following_outbox_url` removed (dead code)
### New features
- `UrlScheme` trait + `DefaultUrlScheme` — configurable URL patterns via `.url_scheme(Arc::new(MyScheme))` on the builder
- `broadcast_raw_to_followers` — send arbitrary AP activity JSON to all accepted followers
- `on_unknown_activity` hook on `ApObjectHandler` — handle custom AP extensions (EmojiReact, Question, Flag, etc.)
- `DbActor::object_id()` convenience method
- `RemoteActor::from_ap_person()`, `RemoteActor::placeholder()` constructors
- Mock builder framework: `MockFollowRepoBuilder`, `MockActorRepoBuilder`, `MockBlocklistRepoBuilder`, `MockActivityRepoBuilder`, `MockUserRepoBuilder`, `MockContentReaderBuilder`, `MockObjectHandlerBuilder`, `MockEventPublisherBuilder`
- `Keypair` named struct (was anonymous tuple)
- `LocalObject` named struct with addressing (was anonymous tuple)
- `FollowMigration::migrate_follower_actor` has a default no-op implementation
- `actor_handler`, `followers_handler`, `following_handler` re-exported for custom router construction
- Constants: `AP_CONTENT_TYPE`, `AP_CONTEXT`, `INBOX_BODY_LIMIT`
### Bug fixes / security
- Fix SSRF bypass via IPv6-mapped IPv4 addresses
- Block additional reserved IP ranges (TEST-NET, benchmarking, reserved, IPv6 documentation)
- `verify_attributed_to` now rejects missing `attributedTo` and handles array form
- Signature failures return 401 instead of 500
- Remove `.expect()` panics from outbox handler
- Unknown activity types accepted gracefully instead of returning 500
### Internal improvements
- thiserror enum for `Error` type (41 boilerplate `.map_err` calls eliminated)
- File organization: `handlers/`, `actors/`, `service/` with `types.rs` per module
- `mock_repo!` macro replaces 980 lines of hand-written mocks
- Small focused functions and descriptive variable names throughout
---
## [0.4.6] — 2026-07-16 ## [0.4.6] — 2026-07-16
### New features ### New features

10
Cargo.lock generated
View File

@@ -1368,7 +1368,7 @@ dependencies = [
[[package]] [[package]]
name = "k-ap" name = "k-ap"
version = "0.4.6" version = "0.5.0"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",
@@ -1377,9 +1377,11 @@ dependencies = [
"chrono", "chrono",
"enum_delegate", "enum_delegate",
"futures", "futures",
"paste",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"url", "url",
@@ -1596,6 +1598,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "k-ap" name = "k-ap"
version = "0.4.6" version = "0.5.0"
edition = "2024" edition = "2024"
description = "Generic ActivityPub protocol layer" description = "Generic ActivityPub protocol layer"
license = "MIT" license = "MIT"
@@ -14,6 +14,7 @@ serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "v5", "serde"] } uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0" anyhow = "1.0"
thiserror = "2"
tracing = "0.1" tracing = "0.1"
async-trait = "0.1" async-trait = "0.1"
axum = { version = "0.8", features = ["macros"] } axum = { version = "0.8", features = ["macros"] }
@@ -22,3 +23,4 @@ url = { version = "2", features = ["serde"] }
enum_delegate = "0.2" enum_delegate = "0.2"
activitypub_federation = "0.7.0-beta.11" activitypub_federation = "0.7.0-beta.11"
zeroize = { version = "1", features = ["derive"] } zeroize = { version = "1", features = ["derive"] }
paste = "1"

View File

@@ -12,7 +12,7 @@ Via the private Gitea registry (recommended):
```toml ```toml
[dependencies] [dependencies]
k-ap = { version = "0.3.0", registry = "gitea" } k-ap = { version = "0.5.0", registry = "gitea" }
``` ```
Configure the registry in `.cargo/config.toml`: Configure the registry in `.cargo/config.toml`:
@@ -26,21 +26,21 @@ Or via git if you don't have registry access:
```toml ```toml
[dependencies] [dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.3.0" } k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.5.0" }
``` ```
## What you implement ## What you implement
Seven focused traits wire your data layer into `k-ap`. Implement them all on a single database struct by cloning the `Arc`, or use separate structs for different backends. Seven supertrait facades wire your data layer into `k-ap`. Implement them all on a single database struct by cloning the `Arc`, or use separate structs for different backends.
```rust ```rust
// Activity deduplication — idempotency for inbound deliveries // Activity deduplication — idempotency for inbound deliveries
impl ActivityRepository for MyDb { ... } impl ActivityRepository for MyDb { ... }
// Follower / following graph + account migration // Follower / following graph + account migration (5 sub-traits)
impl FollowRepository for MyDb { ... } impl FollowRepository for MyDb { ... }
// Local keypairs, remote actor cache, boost (Announce) tracking // Local keypairs, remote actor cache, boost (Announce) tracking (3 sub-traits)
impl ActorRepository for MyDb { ... } impl ActorRepository for MyDb { ... }
// Domain and per-user actor blocklists // Domain and per-user actor blocklists
@@ -56,6 +56,8 @@ impl ApContentReader for MyDb { ... }
impl ApObjectHandler for MyDb { ... } impl ApObjectHandler for MyDb { ... }
``` ```
`FollowRepository` and `ActorRepository` are composed from fine-grained sub-traits (`FollowerWriter`, `FollowerReader`, `FollowingWriter`, `FollowingReader`, `FollowMigration`, `KeypairRepository`, `RemoteActorCache`, `AnnounceRepository`). Implement the supertrait and Rust auto-derives it, or implement sub-traits individually for more control.
## Wire up the service ## Wire up the service
```rust ```rust
@@ -74,6 +76,7 @@ let service = ActivityPubService::builder("https://example.com")
.object_handler(db.clone()) .object_handler(db.clone())
.allow_registration(false) .allow_registration(false)
.software_name("my-app") .software_name("my-app")
// .url_scheme(Arc::new(MyScheme)) // optional: customize URL patterns
.build() .build()
.await?; .await?;
@@ -149,15 +152,15 @@ let mentioned = vec![
]; ];
// Public — to: [AS_PUBLIC], cc: [followers]; delivered to followers + mentioned // Public — to: [AS_PUBLIC], cc: [followers]; delivered to followers + mentioned
service.broadcast_create_note(user_id, note_json, ApVisibility::Public, mentioned).await?; service.broadcast_create(user_id, note_json, ApVisibility::Public, mentioned).await?;
// Followers only — to: [followers], cc: []; delivered to followers + mentioned // Followers only — to: [followers], cc: []; delivered to followers + mentioned
service.broadcast_create_note(user_id, note_json, ApVisibility::FollowersOnly, vec![]).await?; service.broadcast_create(user_id, note_json, ApVisibility::FollowersOnly, vec![]).await?;
// Private — no delivery at all; library returns immediately // Private — no delivery at all; library returns immediately
service.broadcast_create_note(user_id, note_json, ApVisibility::Private, vec![]).await?; service.broadcast_create(user_id, note_json, ApVisibility::Private, vec![]).await?;
service.broadcast_update_note(user_id, note_json, ApVisibility::Public, vec![]).await?; service.broadcast_update(user_id, note_json, ApVisibility::Public, vec![]).await?;
service.broadcast_delete_to_followers(user_id, ap_id).await?; service.broadcast_delete_to_followers(user_id, ap_id).await?;
// Announce / Undo Announce // Announce / Undo Announce
@@ -173,6 +176,9 @@ service.broadcast_actor_update(user_id).await?;
// Account migration — sends Move to all followers; set alsoKnownAs first // Account migration — sends Move to all followers; set alsoKnownAs first
service.broadcast_move(user_id, new_actor_url).await?; service.broadcast_move(user_id, new_actor_url).await?;
// Send arbitrary AP activity JSON to all followers (escape hatch for custom types)
service.broadcast_raw_to_followers(user_id, activity_json).await?;
``` ```
## Follow management ## Follow management
@@ -287,22 +293,87 @@ Handled out of the box:
|------|-------------| |------|-------------|
| `ActivityPubService` | Central service — build once, share via `Arc` | | `ActivityPubService` | Central service — build once, share via `Arc` |
| `ActivityRepository` | Trait: activity ID deduplication (2 methods) | | `ActivityRepository` | Trait: activity ID deduplication (2 methods) |
| `FollowRepository` | Trait: follower/following graph + migration (18 methods) | | `FollowRepository` | Supertrait: follower/following graph + migration (19 methods via 5 sub-traits) |
| `ActorRepository` | Trait: keypairs, remote actor cache, announce tracking (6 methods) | | &emsp;`FollowerWriter` | Sub-trait: add/remove/update follower records (4 methods) |
| &emsp;`FollowerReader` | Sub-trait: query followers, counts, pages (7 methods) |
| &emsp;`FollowingWriter` | Sub-trait: add/remove/update following records (4 methods) |
| &emsp;`FollowingReader` | Sub-trait: query following, counts, pages (3 methods) |
| &emsp;`FollowMigration` | Sub-trait: account migration (1 method, has default no-op) |
| `ActorRepository` | Supertrait: keypairs, remote actor cache, announce tracking (7 methods via 3 sub-traits) |
| &emsp;`KeypairRepository` | Sub-trait: local actor keypair storage (2 methods) |
| &emsp;`RemoteActorCache` | Sub-trait: remote actor upsert/lookup (2 methods) |
| &emsp;`AnnounceRepository` | Sub-trait: boost tracking (3 methods) |
| `BlocklistRepository` | Trait: domain and actor blocklists (8 methods) | | `BlocklistRepository` | Trait: domain and actor blocklists (8 methods) |
| `ApUserRepository` | Trait: user lookup (3 methods) | | `ApUserRepository` | Trait: user lookup (3 methods) |
| `ApContentReader` | Trait: outbox/backfill/featured content (3 methods, 1 with default) | | `ApContentReader` | Trait: outbox/backfill/featured content (3 methods, 1 with default) |
| `ApObjectHandler` | Trait: inbound activity callbacks (9 methods, 2 with defaults) | | `ApObjectHandler` | Trait: inbound activity callbacks (11 methods, 2 with defaults) |
| `UrlScheme` / `DefaultUrlScheme` | Trait + default impl: configurable URL patterns for actors and activities |
| `ApVisibility` | `Public` / `FollowersOnly` / `Private` | | `ApVisibility` | `Public` / `FollowersOnly` / `Private` |
| `ApActorType` | `Person` / `Service` / `Application` / `Organization` / `Group` | | `ApActorType` | `Person` / `Service` / `Application` / `Organization` / `Group` |
| `FederationEvent` | `DeliveryRequested` / `DeliveryFailed` / `BackfillRequested` | | `LocalObject` | Outbox object with addressing fields (`to`, `cc`, `bto`, `bcc`) |
| `Keypair` | Named struct for actor signing keypair |
| `FederationEvent` | `DeliveryRequested` / `DeliveryFailed` / `BackfillRequested` / `OutboundFollowAccepted` |
| `EventPublisher` | Trait: hook for job queue integration | | `EventPublisher` | Trait: hook for job queue integration |
| `LookedUpActor` | Resolved remote actor from `lookup_actor_by_handle` | | `LookedUpActor` | Resolved remote actor from `lookup_actor_by_handle` |
| `RemoteActor` | Cached federated actor record | | `RemoteActor` | Cached federated actor record |
| `Follower` / `FollowerStatus` | Follower with `Pending`/`Accepted`/`Rejected` state | | `Follower` / `FollowerStatus` | Follower with `Pending`/`Accepted`/`Rejected` state |
| `ApUser` | AP-serializable local user | | `ApUser` | AP-serializable local user |
| `ApFederationConfig` | Wraps the `activitypub_federation` config | | `ApFederationConfig` | Wraps the `activitypub_federation` config |
| `Error` | AP-layer error type | | `Error` | thiserror enum: `NotFound` / `BadRequest` / `Unauthorized` / `Forbidden` / `Internal` |
## Custom URL schemes
By default k-ap uses `/users/{uuid}` paths. Implement the `UrlScheme` trait to use custom patterns:
```rust
use k_ap::{UrlScheme, DefaultUrlScheme};
use std::sync::Arc;
struct MyScheme;
impl UrlScheme for MyScheme {
fn actor_url(&self, base_url: &str, user_id: uuid::Uuid) -> anyhow::Result<Url> {
// e.g. https://example.com/@username instead of /users/{uuid}
todo!()
}
fn inbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
fn shared_inbox_url(&self, base_url: &str) -> Option<Url> { todo!() }
fn outbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
fn followers_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
fn following_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
fn activity_url(&self, base_url: &str) -> anyhow::Result<Url> { todo!() }
fn extract_user_id(&self, url: &Url) -> Option<uuid::Uuid> { todo!() }
}
let service = ActivityPubService::builder("https://example.com")
.url_scheme(Arc::new(MyScheme))
// ...repos...
.build()
.await?;
```
## Testing
k-ap ships mock builders for all traits (not behind `#[cfg(test)]` so downstream crates can use them):
```rust
use k_ap::testing::*;
let follow_repo = MockFollowRepoBuilder::new()
.on_add_follower(|id, url, status, _| Ok(()))
.on_count_accepted_followers(|_| Ok(42))
.build();
let actor_repo = MockActorRepoBuilder::new().build();
let blocklist_repo = MockBlocklistRepoBuilder::new().build();
let activity_repo = MockActivityRepoBuilder::new().build();
let user_repo = MockUserRepoBuilder::new().build();
let content_reader = MockContentReaderBuilder::new().build();
let object_handler = MockObjectHandlerBuilder::new().build();
let event_publisher = MockEventPublisherBuilder::new().build();
```
Every unset method defaults to `Ok(Default::default())`.
## Local development ## Local development

View File

@@ -36,9 +36,9 @@ impl Activity for AcceptActivity {
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() { if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!( return Err(Error::bad_request(
"Accept actor does not match Follow target" "Accept actor does not match Follow target",
))); ));
} }
Ok(()) Ok(())
} }
@@ -47,8 +47,10 @@ impl Activity for AcceptActivity {
if check_guards(&self.id, self.actor.inner(), data).await? { if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(()); return Ok(());
} }
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner()) let local_user_id = data
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?; .url_scheme
.extract_user_id(self.object.actor.inner())
.ok_or_else(|| Error::bad_request("invalid actor URL in Follow"))?;
let remote_actor_url = self.actor.inner().as_str().to_string(); let remote_actor_url = self.actor.inner().as_str().to_string();
data.follow_repo data.follow_repo
.update_following_status(local_user_id, &remote_actor_url, FollowingStatus::Accepted) .update_following_status(local_user_id, &remote_actor_url, FollowingStatus::Accepted)
@@ -62,14 +64,17 @@ impl Activity for AcceptActivity {
.await .await
.ok() .ok()
.flatten() .flatten()
.and_then(|a| a.outbox_url); .and_then(|actor| actor.outbox_url);
let _ = publisher if let Err(error) = publisher
.publish(crate::data::FederationEvent::OutboundFollowAccepted { .publish(crate::data::FederationEvent::OutboundFollowAccepted {
local_user_id, local_user_id,
remote_actor_url, remote_actor_url,
outbox_url, outbox_url,
}) })
.await; .await
{
tracing::warn!(%error, "failed to publish OutboundFollowAccepted event");
}
} }
Ok(()) Ok(())
} }

View File

@@ -6,7 +6,7 @@ use crate::actors::DbActor;
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
use super::helpers::check_guards; use super::helpers::{check_guards, extract_object_ap_id, verify_attributed_to};
#[derive(Clone, Default, Debug, Serialize, Deserialize)] #[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Add")] #[serde(rename = "Add")]
@@ -39,34 +39,18 @@ impl Activity for AddActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) verify_attributed_to(&self.object, self.actor.inner(), "Add")
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Add actor does not match object attributedTo"
)));
}
Ok(())
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? { if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(()); return Ok(());
} }
// Use the object's own id as the stable AP identifier, falling back to let ap_id = extract_object_ap_id(&self.object, &self.id);
// the activity id only if the object has no id field.
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone(); let actor_url = self.actor.inner().clone();
data.object_handler data.object_handler
.on_create(&ap_id, &actor_url, self.object) .on_create(&ap_id, &actor_url, self.object)
.await .await?;
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Add activity"); tracing::info!(actor = %actor_url, "received Add activity");
Ok(()) Ok(())
} }

View File

@@ -47,19 +47,28 @@ impl Activity for BlockActivity {
return Ok(()); return Ok(());
} }
let actor_url = self.actor.inner().as_str(); let actor_url = self.actor.inner().as_str();
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) { if let Some(local_user_id) = data.url_scheme.extract_user_id(&self.object) {
let _ = data if let Err(error) = data
.follow_repo .follow_repo
.remove_following(local_user_id, actor_url) .remove_following(local_user_id, actor_url)
.await; .await
let _ = data {
tracing::debug!(%error, "following already removed");
}
if let Err(error) = data
.follow_repo .follow_repo
.remove_follower(local_user_id, actor_url) .remove_follower(local_user_id, actor_url)
.await; .await
let _ = data {
tracing::debug!(%error, "follower already removed");
}
if let Err(error) = data
.blocklist_repo .blocklist_repo
.add_blocked_actor(local_user_id, actor_url) .add_blocked_actor(local_user_id, actor_url)
.await; .await
{
tracing::warn!(%error, "failed to record block");
}
} }
tracing::info!(actor = %actor_url, "received block — removed relationships, recorded in blocklist"); tracing::info!(actor = %actor_url, "received block — removed relationships, recorded in blocklist");
Ok(()) Ok(())

View File

@@ -8,7 +8,9 @@ use crate::actors::DbActor;
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
use super::helpers::{check_guards, extract_and_dispatch_mentions}; use super::helpers::{
check_guards, extract_and_dispatch_mentions, extract_object_ap_id, verify_attributed_to,
};
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -41,33 +43,19 @@ impl Activity for CreateActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) verify_attributed_to(&self.object, self.actor.inner(), "Create")
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Create actor does not match object attributedTo"
)));
}
Ok(())
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? { if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(()); return Ok(());
} }
let ap_id = self let ap_id = extract_object_ap_id(&self.object, &self.id);
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone(); let actor_url = self.actor.inner().clone();
extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await; extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await;
data.object_handler data.object_handler
.on_create(&ap_id, &actor_url, self.object) .on_create(&ap_id, &actor_url, self.object)
.await .await?;
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received create activity"); tracing::info!(actor = %actor_url, "received create activity");
Ok(()) Ok(())
} }

View File

@@ -52,9 +52,9 @@ impl Activity for DeleteActivity {
_ => String::new(), _ => String::new(),
}; };
if !object_domain.is_empty() && actor_domain != object_domain { if !object_domain.is_empty() && actor_domain != object_domain {
return Err(Error::bad_request(anyhow::anyhow!( return Err(Error::bad_request(
"Delete actor domain does not match object domain" "Delete actor domain does not match object domain",
))); ));
} }
Ok(()) Ok(())
} }
@@ -78,17 +78,13 @@ impl Activity for DeleteActivity {
return Ok(()); return Ok(());
}; };
if object_url == *self.actor.inner() { if object_url == *self.actor.inner() {
data.object_handler data.object_handler.on_actor_removed(&actor_url).await?;
.on_actor_removed(&actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted"); tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted");
return Ok(()); return Ok(());
} }
data.object_handler data.object_handler
.on_delete(&object_url, &actor_url) .on_delete(&object_url, &actor_url)
.await .await?;
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(object = %object_url, "received Delete(note)"); tracing::info!(object = %object_url, "received Delete(note)");
Ok(()) Ok(())
} }

View File

@@ -39,15 +39,13 @@ impl Activity for FollowActivity {
(Some(host), Some(port)) => format!("{}:{}", host, port), (Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(), (Some(host), None) => host.to_string(),
_ => { _ => {
return Err(Error::bad_request(anyhow::anyhow!( return Err(Error::bad_request("invalid follow target URL"));
"invalid follow target URL"
)));
} }
}; };
if target_domain == data.domain { if target_domain == data.domain {
return Ok(()); return Ok(());
} }
if let Some(uuid) = crate::urls::extract_user_id_from_url(target_url) if let Some(uuid) = data.url_scheme.extract_user_id(target_url)
&& data && data
.user_repo .user_repo
.find_by_id(uuid) .find_by_id(uuid)
@@ -59,9 +57,7 @@ impl Activity for FollowActivity {
tracing::debug!(target = %target_url, "accepting follow for migrated actor URL"); tracing::debug!(target = %target_url, "accepting follow for migrated actor URL");
return Ok(()); return Ok(());
} }
Err(Error::bad_request(anyhow::anyhow!( Err(Error::bad_request("follow target is not a local actor"))
"follow target is not a local actor"
)))
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
@@ -69,7 +65,7 @@ impl Activity for FollowActivity {
return Ok(()); return Ok(());
} }
// Actor block checked BEFORE any outbound HTTP fetch. // Actor block checked BEFORE any outbound HTTP fetch.
if let Some(target_user_id) = crate::urls::extract_user_id_from_url(self.object.inner()) if let Some(target_user_id) = data.url_scheme.extract_user_id(self.object.inner())
&& data && data
.blocklist_repo .blocklist_repo
.is_actor_blocked(target_user_id, self.actor.inner().as_str()) .is_actor_blocked(target_user_id, self.actor.inner().as_str())

View File

@@ -47,6 +47,53 @@ pub(crate) async fn check_guards(
Ok(false) Ok(false)
} }
pub(crate) fn verify_attributed_to(
object: &serde_json::Value,
actor: &Url,
activity_name: &str,
) -> Result<(), Error> {
let attributed_to = object.get("attributedTo").ok_or_else(|| {
Error::bad_request(format!("{activity_name} object missing attributedTo"))
})?;
let actor_urls: Vec<&str> = if let Some(url_str) = attributed_to.as_str() {
vec![url_str]
} else if let Some(array) = attributed_to.as_array() {
array
.iter()
.filter_map(|entry| {
entry
.as_str()
.or_else(|| entry.get("id").and_then(|id| id.as_str()))
})
.collect()
} else {
return Err(Error::bad_request(format!(
"{activity_name} object has invalid attributedTo",
)));
};
let matches_actor = actor_urls
.iter()
.any(|url_str| Url::parse(url_str).as_ref() == Ok(actor));
if !matches_actor {
return Err(Error::bad_request(format!(
"{activity_name} actor does not match object attributedTo",
)));
}
Ok(())
}
pub(crate) fn extract_object_ap_id(object: &serde_json::Value, fallback: &Url) -> Url {
object
.get("id")
.and_then(|value| value.as_str())
.and_then(|id_str| Url::parse(id_str).ok())
.unwrap_or_else(|| fallback.clone())
}
/// Parse `object["tag"]` for `Mention` entries and notify each tagged local user. /// Parse `object["tag"]` for `Mention` entries and notify each tagged local user.
/// Failures are logged and never propagated — a broken mention must not fail the activity. /// Failures are logged and never propagated — a broken mention must not fail the activity.
pub(crate) async fn extract_and_dispatch_mentions( pub(crate) async fn extract_and_dispatch_mentions(
@@ -55,7 +102,7 @@ pub(crate) async fn extract_and_dispatch_mentions(
object: &serde_json::Value, object: &serde_json::Value,
data: &Data<FederationData>, data: &Data<FederationData>,
) { ) {
let Some(tags) = object.get("tag").and_then(|t| t.as_array()) else { let Some(tags) = object.get("tag").and_then(|tags| tags.as_array()) else {
return; return;
}; };
for tag in tags { for tag in tags {
@@ -68,7 +115,7 @@ pub(crate) async fn extract_and_dispatch_mentions(
let Ok(href_url) = Url::parse(href) else { let Ok(href_url) = Url::parse(href) else {
continue; continue;
}; };
let Some(mentioned_user_id) = crate::urls::extract_user_id_from_url(&href_url) else { let Some(mentioned_user_id) = data.url_scheme.extract_user_id(&href_url) else {
continue; continue;
}; };
if let Err(e) = data if let Err(e) = data

View File

@@ -57,8 +57,7 @@ impl Activity for LikeActivity {
} }
data.object_handler data.object_handler
.on_like(&self.object, self.actor.inner()) .on_like(&self.object, self.actor.inner())
.await .await?;
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like"); tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
Ok(()) Ok(())
} }

View File

@@ -1,26 +1,26 @@
mod accept; mod accept;
mod add; mod add;
mod announce; pub(crate) mod announce;
mod block; pub(crate) mod block;
mod create; mod create;
mod delete; mod delete;
mod follow; mod follow;
pub(crate) mod helpers; pub(crate) mod helpers;
mod like; pub(crate) mod like;
mod move_act; mod move_act;
mod reject; mod reject;
mod undo; mod undo;
mod update; mod update;
pub use accept::AcceptActivity; pub use accept::AcceptActivity;
pub use add::{AddActivity, AddType}; pub use add::AddActivity;
pub use announce::{AnnounceActivity, AnnounceType}; pub use announce::AnnounceActivity;
pub use block::{BlockActivity, BlockType}; pub use block::BlockActivity;
pub use create::CreateActivity; pub use create::CreateActivity;
pub use delete::DeleteActivity; pub use delete::DeleteActivity;
pub use follow::FollowActivity; pub use follow::FollowActivity;
pub use like::{LikeActivity, LikeType}; pub use like::LikeActivity;
pub use move_act::{MoveActivity, MoveType}; pub use move_act::MoveActivity;
pub use reject::RejectActivity; pub use reject::RejectActivity;
pub use undo::UndoActivity; pub use undo::UndoActivity;
pub use update::UpdateActivity; pub use update::UpdateActivity;

View File

@@ -41,9 +41,7 @@ impl Activity for MoveActivity {
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if &self.object != self.actor.inner() { if &self.object != self.actor.inner() {
return Err(Error::bad_request(anyhow::anyhow!( return Err(Error::bad_request("Move object must be the actor itself"));
"Move object must be the actor itself"
)));
} }
Ok(()) Ok(())
} }
@@ -52,23 +50,21 @@ impl Activity for MoveActivity {
if check_guards(&self.id, self.actor.inner(), data).await? { if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(()); return Ok(());
} }
let target = ObjectId::<DbActor>::from(self.target.clone()) let target = ObjectId::<DbActor>::from(self.target.clone())
.dereference(data) .dereference(data)
.await .await?;
.map_err(|e| Error::from(anyhow::anyhow!("{e}")))?;
// Verify the new actor claims the old identity via alsoKnownAs.
// The spec allows multiple aliases; check all of them.
let old_url = self.object.as_str(); let old_url = self.object.as_str();
if !target.also_known_as.iter().any(|a| a == old_url) { if !target.also_known_as.iter().any(|a| a == old_url) {
return Err(Error::bad_request(anyhow::anyhow!( return Err(Error::bad_request(
"Move target alsoKnownAs does not reference old actor" "Move target alsoKnownAs does not reference old actor",
))); ));
} }
let affected = data let affected = data
.follow_repo .follow_repo
.migrate_follower_actor(old_url, self.target.as_str()) .migrate_follower_actor(old_url, self.target.as_str())
.await .await?;
.map_err(|e| Error::from(anyhow::anyhow!("{e}")))?;
let affected_count = affected.len(); let affected_count = affected.len();
// Spawn re-follows in the background — do NOT await them inside receive() // Spawn re-follows in the background — do NOT await them inside receive()
@@ -79,53 +75,20 @@ impl Activity for MoveActivity {
let data_clone = data.clone(); let data_clone = data.clone();
tokio::spawn(async move { tokio::spawn(async move {
for local_user_id in &affected { for local_user_id in &affected {
let local_actor = if let Err(e) = send_refollow(
match crate::actors::get_local_actor(*local_user_id, &data_clone).await { *local_user_id,
Ok(a) => a, &target_url,
Err(e) => { &target_inbox,
tracing::warn!( &base_url,
error = %e,
%local_user_id,
"Move: failed to load local actor"
);
continue;
}
};
let follow_id = match crate::urls::activity_url(&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: ObjectId::from(local_actor.ap_id.clone()),
object: ObjectId::from(target_url.clone()),
};
let sends = match SendActivityTask::prepare(
&WithContext::new_default(follow),
&local_actor,
vec![target_inbox.clone()],
&data_clone, &data_clone,
) )
.await .await
{ {
Ok(s) => s, tracing::warn!(
Err(e) => { error = %e,
tracing::warn!(error = %e, "Move: failed to prepare re-follow"); %local_user_id,
continue; "Move: re-follow failed"
} );
};
for send in sends {
if let Err(e) = send.sign_and_send(&data_clone).await {
tracing::warn!(
error = %e,
%local_user_id,
"Move: re-follow delivery failed"
);
}
} }
} }
}); });
@@ -139,3 +102,40 @@ impl Activity for MoveActivity {
Ok(()) Ok(())
} }
} }
async fn send_refollow(
local_user_id: uuid::Uuid,
new_target_url: &Url,
new_target_inbox: &Url,
base_url: &str,
data: &Data<FederationData>,
) -> anyhow::Result<()> {
let local_actor = crate::actors::get_local_actor(local_user_id, data).await?;
let follow_id = data.url_scheme.activity_url(base_url)?;
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: ObjectId::from(new_target_url.clone()),
};
let sends = SendActivityTask::prepare(
&WithContext::new_default(follow),
&local_actor,
vec![new_target_inbox.clone()],
data,
)
.await?;
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"
);
}
}
Ok(())
}

View File

@@ -35,9 +35,9 @@ impl Activity for RejectActivity {
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() { if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!( return Err(Error::bad_request(
"Reject actor does not match Follow target" "Reject actor does not match Follow target",
))); ));
} }
Ok(()) Ok(())
} }
@@ -46,7 +46,7 @@ impl Activity for RejectActivity {
if check_guards(&self.id, self.actor.inner(), data).await? { if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(()); return Ok(());
} }
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) { if let Some(user_id) = data.url_scheme.extract_user_id(self.object.actor.inner()) {
data.follow_repo data.follow_repo
.remove_following(user_id, self.actor.inner().as_str()) .remove_following(user_id, self.actor.inner().as_str())
.await?; .await?;

View File

@@ -36,9 +36,9 @@ impl Activity for UndoActivity {
if let Some(inner_actor) = self.object.get("actor").and_then(|v| v.as_str()) if let Some(inner_actor) = self.object.get("actor").and_then(|v| v.as_str())
&& inner_actor != self.actor.inner().as_str() && inner_actor != self.actor.inner().as_str()
{ {
return Err(Error::bad_request(anyhow::anyhow!( return Err(Error::bad_request(
"Undo actor does not match inner activity actor" "Undo actor does not match inner activity actor",
))); ));
} }
Ok(()) Ok(())
} }
@@ -50,98 +50,14 @@ impl Activity for UndoActivity {
let obj_type = self let obj_type = self
.object .object
.get("type") .get("type")
.and_then(|t| t.as_str()) .and_then(|type_value| type_value.as_str())
.unwrap_or(""); .unwrap_or("");
match obj_type { match obj_type {
"Follow" => { "Follow" => handle_undo_follow(self.actor.inner(), &self.object, data).await?,
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str()) "Add" => handle_undo_add(self.actor.inner(), &self.object, data).await?,
&& let Ok(url) = Url::parse(obj_url) "Like" => handle_undo_like(self.actor.inner(), &self.object, data).await?,
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url) "Announce" => handle_undo_announce(self.actor.inner(), &self.object, data).await?,
{ "Block" => handle_undo_block(self.actor.inner(), &self.object, data).await?,
data.follow_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
}
"Add" => {
let ap_id_str = self
.object
.get("object")
.and_then(|o| o.get("id"))
.and_then(|id| id.as_str())
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
if let Some(ap_id_str) = ap_id_str
&& let Ok(ap_id) = Url::parse(ap_id_str)
{
data.object_handler
.on_delete(&ap_id, self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
}
}
"Like" => {
if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(obj_url) = Url::parse(obj_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_unlike(&obj_url, self.actor.inner())
.await
.unwrap_or_else(|e| tracing::warn!(error = %e, "failed to process unlike"));
}
tracing::info!(actor = %self.actor.inner(), "received Undo(Like)");
}
"Announce" => {
// Remove the boost record so announce counts stay accurate.
let activity_id = self.object.get("id").and_then(|v| v.as_str()).unwrap_or("");
let object_url_str = self
.object
.get("object")
.and_then(|v| v.as_str())
.unwrap_or("");
if !activity_id.is_empty()
&& let Err(e) = data
.actor_repo
.remove_announce(activity_id, self.actor.inner().as_str())
.await
{
tracing::warn!(error = %e, activity_id, "failed to remove announce record");
}
if let Ok(obj_url) = Url::parse(object_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_announce_removed(&obj_url, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process Undo(Announce)");
});
}
tracing::info!(actor = %self.actor.inner(), "received Undo(Announce)");
}
"Block" => {
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(url) = Url::parse(obj_url)
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url)
{
let _ = data
.blocklist_repo
.remove_blocked_actor(user_id, self.actor.inner().as_str())
.await;
}
tracing::info!(
actor = %self.actor.inner(),
"received Undo(Block) — removed from blocklist"
);
}
other => { other => {
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type"); tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
} }
@@ -149,3 +65,117 @@ impl Activity for UndoActivity {
Ok(()) Ok(())
} }
} }
async fn handle_undo_follow(
actor: &Url,
object: &serde_json::Value,
data: &Data<FederationData>,
) -> Result<(), Error> {
if let Some(obj_url) = object.get("object").and_then(|inner| inner.as_str())
&& let Ok(url) = Url::parse(obj_url)
&& let Some(user_id) = data.url_scheme.extract_user_id(&url)
{
data.follow_repo
.remove_follower(user_id, actor.as_str())
.await?;
}
data.object_handler.on_actor_removed(actor).await?;
tracing::info!(actor = %actor, "unfollowed");
Ok(())
}
async fn handle_undo_add(
actor: &Url,
object: &serde_json::Value,
data: &Data<FederationData>,
) -> Result<(), Error> {
let ap_id_str = object
.get("object")
.and_then(|inner| inner.get("id"))
.and_then(|id| id.as_str())
.or_else(|| object.get("id").and_then(|id| id.as_str()));
if let Some(ap_id_str) = ap_id_str
&& let Ok(ap_id) = Url::parse(ap_id_str)
{
data.object_handler.on_delete(&ap_id, actor).await?;
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
}
Ok(())
}
async fn handle_undo_like(
actor: &Url,
object: &serde_json::Value,
data: &Data<FederationData>,
) -> Result<(), Error> {
if let Some(obj_url_str) = object.get("object").and_then(|inner| inner.as_str())
&& let Ok(obj_url) = Url::parse(obj_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_unlike(&obj_url, actor)
.await
.unwrap_or_else(|e| tracing::warn!(error = %e, "failed to process unlike"));
}
tracing::info!(actor = %actor, "received Undo(Like)");
Ok(())
}
async fn handle_undo_announce(
actor: &Url,
object: &serde_json::Value,
data: &Data<FederationData>,
) -> Result<(), Error> {
// Remove the boost record so announce counts stay accurate.
let activity_id = object.get("id").and_then(|v| v.as_str()).unwrap_or("");
let object_url_str = object.get("object").and_then(|v| v.as_str()).unwrap_or("");
if !activity_id.is_empty()
&& let Err(e) = data
.actor_repo
.remove_announce(activity_id, actor.as_str())
.await
{
tracing::warn!(error = %e, activity_id, "failed to remove announce record");
}
if let Ok(obj_url) = Url::parse(object_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_announce_removed(&obj_url, actor)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process Undo(Announce)");
});
}
tracing::info!(actor = %actor, "received Undo(Announce)");
Ok(())
}
async fn handle_undo_block(
actor: &Url,
object: &serde_json::Value,
data: &Data<FederationData>,
) -> Result<(), Error> {
if let Some(obj_url) = object.get("object").and_then(|inner| inner.as_str())
&& let Ok(url) = Url::parse(obj_url)
&& let Some(user_id) = data.url_scheme.extract_user_id(&url)
&& let Err(error) = data
.blocklist_repo
.remove_blocked_actor(user_id, actor.as_str())
.await
{
tracing::debug!(%error, "block record already removed");
}
tracing::info!(
actor = %actor,
"received Undo(Block) — removed from blocklist"
);
Ok(())
}

View File

@@ -8,7 +8,9 @@ use crate::actors::DbActor;
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
use super::helpers::{check_guards, extract_and_dispatch_mentions}; use super::helpers::{
check_guards, extract_and_dispatch_mentions, extract_object_ap_id, verify_attributed_to,
};
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -37,33 +39,19 @@ impl Activity for UpdateActivity {
} }
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) verify_attributed_to(&self.object, self.actor.inner(), "Update")
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Update actor does not match object attributedTo"
)));
}
Ok(())
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? { if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(()); return Ok(());
} }
let ap_id = self let ap_id = extract_object_ap_id(&self.object, &self.id);
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone(); let actor_url = self.actor.inner().clone();
extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await; extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await;
data.object_handler data.object_handler
.on_update(&ap_id, &actor_url, self.object) .on_update(&ap_id, &actor_url, self.object)
.await .await?;
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received update activity"); tracing::info!(actor = %actor_url, "received update activity");
Ok(()) Ok(())
} }

View File

@@ -1,453 +0,0 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair,
protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::{Actor, Object},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::RemoteActor;
use crate::user::{ApActorType, ApProfileField};
#[derive(Debug, Clone)]
pub struct DbActor {
pub user_id: uuid::Uuid,
pub username: String,
pub display_name: Option<String>,
pub public_key_pem: String,
/// Private key PEM. Only populated for local actors during signing.
/// Cleared automatically when `DbActor` is dropped.
pub private_key_pem: Option<String>,
pub inbox_url: Url,
pub shared_inbox_url: Option<Url>,
pub outbox_url: Url,
pub followers_url: Url,
pub following_url: Url,
pub ap_id: Url,
pub last_refreshed_at: DateTime<Utc>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub also_known_as: Vec<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
pub manually_approves_followers: bool,
pub discoverable: bool,
pub actor_type: ApActorType,
pub featured_url: Option<Url>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApImageObject {
#[serde(rename = "type")]
pub kind: String,
pub url: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Endpoints {
pub shared_inbox: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileFieldObject {
#[serde(rename = "type")]
pub kind: String,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
#[serde(rename = "type")]
kind: ApActorType,
id: ObjectId<DbActor>,
#[serde(default)]
preferred_username: String,
inbox: Url,
#[serde(default)]
outbox: Option<Url>,
#[serde(default)]
followers: Option<Url>,
#[serde(default)]
following: Option<Url>,
pub public_key: PublicKey,
#[serde(default)]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<ApImageObject>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>,
#[serde(default)]
manually_approves_followers: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
updated: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
endpoints: Option<Endpoints>,
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<ApImageObject>,
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
also_known_as: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
attachment: Vec<ProfileFieldObject>,
#[serde(skip_serializing_if = "Option::is_none")]
featured: Option<Url>,
}
struct ActorUrls {
ap_id: Url,
inbox_url: Url,
shared_inbox_url: Option<Url>,
outbox_url: Url,
followers_url: Url,
following_url: Url,
}
impl ActorUrls {
fn build(base_url: &str, user_id: uuid::Uuid) -> Self {
let ap_id = crate::urls::actor_url(base_url, user_id);
Self {
inbox_url: Url::parse(&format!("{}/inbox", ap_id)).expect("valid url"),
shared_inbox_url: Url::parse(&format!("{}/inbox", base_url)).ok(),
outbox_url: Url::parse(&format!("{}/outbox", ap_id)).expect("valid url"),
followers_url: Url::parse(&format!("{}/followers", ap_id)).expect("valid url"),
following_url: Url::parse(&format!("{}/following", ap_id)).expect("valid url"),
ap_id,
}
}
}
pub async fn get_local_actor(
user_id: uuid::Uuid,
data: &Data<FederationData>,
) -> Result<DbActor, Error> {
build_local_actor(
user_id,
&data.base_url,
data.user_repo.as_ref(),
data.actor_repo.as_ref(),
)
.await
.map_err(|e| Error::not_found(anyhow::anyhow!("{e}")))
}
/// Build a local actor's `DbActor` from repository data. Generates a keypair
/// if one doesn't exist yet. Usable outside of a `FederationData` context
/// (e.g. during service construction).
pub async fn build_local_actor(
user_id: uuid::Uuid,
base_url: &str,
user_repo: &dyn crate::user::ApUserRepository,
actor_repo: &dyn crate::repository::ActorRepository,
) -> anyhow::Result<DbActor> {
let user = user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not found: {}", user_id))?;
let (public_key, private_key) = match actor_repo.get_local_actor_keypair(user_id).await? {
Some(kp) => kp,
None => {
let kp = generate_actor_keypair()?;
let private_zeroized = Zeroizing::new(kp.private_key.clone());
actor_repo
.save_local_actor_keypair(
user_id,
kp.public_key.clone(),
private_zeroized.clone().to_string(),
)
.await?;
drop(private_zeroized);
(kp.public_key, kp.private_key)
}
};
let ActorUrls {
ap_id,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
} = ActorUrls::build(base_url, user_id);
Ok(DbActor {
user_id,
username: user.username,
display_name: user.display_name,
public_key_pem: public_key,
private_key_pem: Some(private_key),
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
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,
manually_approves_followers: user.manually_approves_followers,
discoverable: user.discoverable,
actor_type: user.actor_type,
featured_url: user.featured_url,
})
}
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]
impl Object for DbActor {
type DataType = FederationData;
type Kind = Person;
type Error = Error;
fn id(&self) -> &Url {
&self.ap_id
}
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
Some(self.last_refreshed_at)
}
async fn read_from_id(
object_id: Url,
data: &Data<Self::DataType>,
) -> Result<Option<Self>, Self::Error> {
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
Some(id) => id,
None => return Ok(None),
};
let user = match data.user_repo.find_by_id(user_id).await {
Ok(Some(u)) => u,
_ => return Ok(None),
};
let keypair = data.actor_repo.get_local_actor_keypair(user_id).await?;
let (public_key, private_key) = match keypair {
Some(kp) => (kp.0, Some(kp.1)),
None => return Ok(None),
};
let ActorUrls {
ap_id,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
} = ActorUrls::build(&data.base_url, user_id);
Ok(Some(DbActor {
user_id,
username: user.username.clone(),
display_name: user.display_name,
public_key_pem: public_key,
private_key_pem: private_key,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
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,
manually_approves_followers: user.manually_approves_followers,
discoverable: user.discoverable,
actor_type: user.actor_type,
featured_url: user.featured_url,
}))
}
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey {
id: format!("{}#main-key", self.ap_id),
owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(),
};
let icon = self.avatar_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let image = self.banner_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let also_known_as = self.also_known_as;
let attachment: Vec<ProfileFieldObject> = self
.attachment
.into_iter()
.map(|f| ProfileFieldObject {
kind: "PropertyValue".to_string(),
name: f.name,
value: f.value,
})
.collect();
let shared_inbox =
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
Ok(Person {
kind: self.actor_type,
id: self.ap_id.clone().into(),
preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(),
outbox: Some(self.outbox_url.clone()),
followers: Some(self.followers_url.clone()),
following: Some(self.following_url.clone()),
public_key,
name: self.display_name.or_else(|| Some(self.username.clone())),
summary: self.bio.clone(),
icon,
url: self.profile_url,
discoverable: Some(self.discoverable),
manually_approves_followers: self.manually_approves_followers,
updated: Some(self.last_refreshed_at),
endpoints: Some(Endpoints { shared_inbox }),
image,
also_known_as,
attachment,
featured: self.featured_url,
})
}
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
if verify_domains_match(json.id.inner(), expected_domain).is_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> {
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 actor = RemoteActor {
url: json.id.inner().to_string(),
handle: json.preferred_username.clone(),
inbox_url: json.inbox.to_string(),
shared_inbox_url,
display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
outbox_url: json.outbox.as_ref().map(|u| u.to_string()),
bio: json.summary.clone(),
banner_url: json.image.as_ref().map(|i| i.url.to_string()),
followers_url: json.followers.as_ref().map(|u| u.to_string()),
following_url: json.following.as_ref().map(|u| u.to_string()),
also_known_as: json.also_known_as.clone(),
fetched_at: Some(Utc::now()),
};
data.actor_repo.upsert_remote_actor(actor).await?;
let url_str = json.id.inner().to_string();
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
let ap_id = json.id.inner().clone();
let inbox_url = json.inbox.clone();
let shared_inbox_url = json
.endpoints
.as_ref()
.and_then(|e| Url::parse(e.shared_inbox.as_str()).ok());
let fallback = |suffix: &str| {
Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.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 {
user_id,
username: json.preferred_username.clone(),
display_name: json.name.clone(),
public_key_pem: json.public_key.public_key_pem,
private_key_pem: None,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: json.summary.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.clone()),
banner_url: json.image.as_ref().map(|i| i.url.clone()),
also_known_as: json.also_known_as,
profile_url: json.url.clone(),
attachment: json
.attachment
.iter()
.map(|f| crate::user::ApProfileField {
name: f.name.clone(),
value: f.value.clone(),
})
.collect(),
manually_approves_followers: json.manually_approves_followers,
discoverable: json.discoverable.unwrap_or(false),
actor_type: json.kind,
featured_url: json.featured,
})
}
}
impl Actor for DbActor {
fn public_key_pem(&self) -> &str {
&self.public_key_pem
}
fn private_key_pem(&self) -> Option<String> {
self.private_key_pem.clone()
}
fn inbox(&self) -> Url {
self.inbox_url.clone()
}
}
#[cfg(test)]
#[path = "tests/actors.rs"]
mod tests;

121
src/actors/mod.rs Normal file
View File

@@ -0,0 +1,121 @@
mod person;
mod types;
pub use types::{DbActor, Person};
use activitypub_federation::{
config::Data, http_signatures::generate_actor_keypair, traits::Actor,
};
use chrono::Utc;
use url::Url;
use zeroize::Zeroizing;
use crate::data::FederationData;
use crate::error::Error;
use types::ActorUrls;
pub async fn get_local_actor(
user_id: uuid::Uuid,
data: &Data<FederationData>,
) -> Result<DbActor, Error> {
build_local_actor(
user_id,
&data.base_url,
data.user_repo.as_ref(),
data.actor_repo.as_ref(),
data.url_scheme.as_ref(),
)
.await
.map_err(|error| Error::not_found(error.to_string()))
}
/// Build a local actor's `DbActor` from repository data. Generates a keypair
/// if one doesn't exist yet. Usable outside of a `FederationData` context
/// (e.g. during service construction).
pub async fn build_local_actor(
user_id: uuid::Uuid,
base_url: &str,
user_repo: &dyn crate::user::ApUserRepository,
actor_repo: &dyn crate::repository::ActorRepository,
url_scheme: &dyn crate::url_scheme::UrlScheme,
) -> anyhow::Result<DbActor> {
let user = user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not found: {}", user_id))?;
let keypair = match actor_repo.get_local_actor_keypair(user_id).await? {
Some(existing) => existing,
None => {
let generated = generate_actor_keypair()?;
let keypair = crate::repository::Keypair {
public_key: generated.public_key,
private_key: generated.private_key.clone(),
};
let private_zeroized = Zeroizing::new(generated.private_key);
actor_repo
.save_local_actor_keypair(user_id, keypair.clone())
.await?;
drop(private_zeroized);
keypair
}
};
let ActorUrls {
ap_id,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
} = ActorUrls::build(base_url, user_id, url_scheme)?;
Ok(DbActor {
user_id,
username: user.username,
display_name: user.display_name,
public_key_pem: keypair.public_key,
private_key_pem: Some(keypair.private_key),
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
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,
manually_approves_followers: user.manually_approves_followers,
discoverable: user.discoverable,
actor_type: user.actor_type,
featured_url: user.featured_url,
})
}
fn apex_domain(url: &Url) -> String {
let host = url.host_str().unwrap_or("");
host.strip_prefix("www.").unwrap_or(host).to_owned()
}
impl Actor for DbActor {
fn public_key_pem(&self) -> &str {
&self.public_key_pem
}
fn private_key_pem(&self) -> Option<String> {
self.private_key_pem.clone()
}
fn inbox(&self) -> Url {
self.inbox_url.clone()
}
}
#[cfg(test)]
#[path = "tests/actors.rs"]
mod tests;

197
src/actors/person.rs Normal file
View File

@@ -0,0 +1,197 @@
use activitypub_federation::{
config::Data,
protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::Object,
};
use chrono::{DateTime, Utc};
use url::Url;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::RemoteActor;
use super::types::{ApImageObject, DbActor, Endpoints, Person, ProfileFieldObject};
use super::{apex_domain, build_local_actor};
#[async_trait::async_trait]
impl Object for DbActor {
type DataType = FederationData;
type Kind = Person;
type Error = Error;
fn id(&self) -> &Url {
&self.ap_id
}
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
Some(self.last_refreshed_at)
}
async fn read_from_id(
object_id: Url,
data: &Data<Self::DataType>,
) -> Result<Option<Self>, Self::Error> {
let user_id = match data.url_scheme.extract_user_id(&object_id) {
Some(id) => id,
None => return Ok(None),
};
if data
.actor_repo
.get_local_actor_keypair(user_id)
.await?
.is_none()
{
return Ok(None);
}
match build_local_actor(
user_id,
&data.base_url,
data.user_repo.as_ref(),
data.actor_repo.as_ref(),
data.url_scheme.as_ref(),
)
.await
{
Ok(actor) => Ok(Some(actor)),
Err(_) => Ok(None),
}
}
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey {
id: format!("{}#main-key", self.ap_id),
owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(),
};
let icon = self.avatar_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let image = self.banner_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let also_known_as = self.also_known_as;
let attachment: Vec<ProfileFieldObject> = self
.attachment
.into_iter()
.map(|field| ProfileFieldObject {
kind: "PropertyValue".to_string(),
name: field.name,
value: field.value,
})
.collect();
let shared_inbox = data
.url_scheme
.shared_inbox_url(&data.base_url)
.ok_or_else(|| anyhow::anyhow!("invalid base_url for shared inbox"))?;
Ok(Person {
kind: self.actor_type,
id: self.ap_id.clone().into(),
preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(),
outbox: Some(self.outbox_url.clone()),
followers: Some(self.followers_url.clone()),
following: Some(self.following_url.clone()),
public_key,
name: self.display_name.or_else(|| Some(self.username.clone())),
summary: self.bio.clone(),
icon,
url: self.profile_url,
discoverable: Some(self.discoverable),
manually_approves_followers: self.manually_approves_followers,
updated: Some(self.last_refreshed_at),
endpoints: Some(Endpoints { shared_inbox }),
image,
also_known_as,
attachment,
featured: self.featured_url,
})
}
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
if verify_domains_match(json.id.inner(), expected_domain).is_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> {
tracing::debug!(
actor_id = %json.id.inner(),
username = %json.preferred_username,
"ingesting remote actor"
);
let cached_actor = RemoteActor::from_ap_person(&json);
data.actor_repo.upsert_remote_actor(cached_actor).await?;
let url_str = json.id.inner().to_string();
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
let ap_id = json.id.inner().clone();
let inbox_url = json.inbox.clone();
let shared_inbox_url = json
.endpoints
.as_ref()
.and_then(|endpoints| Url::parse(endpoints.shared_inbox.as_str()).ok());
let fallback = |suffix: &str| {
Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.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 {
user_id,
username: json.preferred_username.clone(),
display_name: json.name.clone(),
public_key_pem: json.public_key.public_key_pem,
private_key_pem: None,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: json.summary.clone(),
avatar_url: json.icon.as_ref().map(|icon| icon.url.clone()),
banner_url: json.image.as_ref().map(|image| image.url.clone()),
also_known_as: json.also_known_as,
profile_url: json.url.clone(),
attachment: json
.attachment
.iter()
.map(|field| crate::user::ApProfileField {
name: field.name.clone(),
value: field.value.clone(),
})
.collect(),
manually_approves_followers: json.manually_approves_followers,
discoverable: json.discoverable.unwrap_or(false),
actor_type: json.kind,
featured_url: json.featured,
})
}
}

View File

@@ -1,3 +1,4 @@
use super::types::{ApImageObject, Endpoints};
use super::*; use super::*;
// ── Person AP JSON serialization ────────────────────────────────────────────── // ── Person AP JSON serialization ──────────────────────────────────────────────

131
src/actors/types.rs Normal file
View File

@@ -0,0 +1,131 @@
use activitypub_federation::fetch::object_id::ObjectId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::url_scheme::UrlScheme;
use crate::user::{ApActorType, ApProfileField};
#[derive(Debug, Clone)]
pub struct DbActor {
pub user_id: uuid::Uuid,
pub username: String,
pub display_name: Option<String>,
pub public_key_pem: String,
/// Private key PEM. Only populated for local actors during signing.
/// Cleared automatically when `DbActor` is dropped.
pub private_key_pem: Option<String>,
pub inbox_url: Url,
pub shared_inbox_url: Option<Url>,
pub outbox_url: Url,
pub followers_url: Url,
pub following_url: Url,
pub ap_id: Url,
pub last_refreshed_at: DateTime<Utc>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub also_known_as: Vec<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
pub manually_approves_followers: bool,
pub discoverable: bool,
pub actor_type: ApActorType,
pub featured_url: Option<Url>,
}
impl DbActor {
pub fn object_id(&self) -> ObjectId<Self> {
ObjectId::from(self.ap_id.clone())
}
}
pub(super) struct ActorUrls {
pub(super) ap_id: Url,
pub(super) inbox_url: Url,
pub(super) shared_inbox_url: Option<Url>,
pub(super) outbox_url: Url,
pub(super) followers_url: Url,
pub(super) following_url: Url,
}
impl ActorUrls {
pub(super) fn build(
base_url: &str,
user_id: uuid::Uuid,
url_scheme: &dyn UrlScheme,
) -> anyhow::Result<Self> {
let ap_id = url_scheme.actor_url(base_url, user_id)?;
Ok(Self {
inbox_url: url_scheme.inbox_url(&ap_id)?,
shared_inbox_url: url_scheme.shared_inbox_url(base_url),
outbox_url: url_scheme.outbox_url(&ap_id)?,
followers_url: url_scheme.followers_url(&ap_id)?,
following_url: url_scheme.following_url(&ap_id)?,
ap_id,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApImageObject {
#[serde(rename = "type")]
pub kind: String,
pub url: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Endpoints {
pub shared_inbox: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileFieldObject {
#[serde(rename = "type")]
pub kind: String,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
#[serde(rename = "type")]
pub(crate) kind: ApActorType,
pub(crate) id: ObjectId<DbActor>,
#[serde(default)]
pub(crate) preferred_username: String,
pub(crate) inbox: Url,
#[serde(default)]
pub(crate) outbox: Option<Url>,
#[serde(default)]
pub(crate) followers: Option<Url>,
#[serde(default)]
pub(crate) following: Option<Url>,
pub public_key: activitypub_federation::protocol::public_key::PublicKey,
#[serde(default)]
pub(crate) name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) icon: Option<ApImageObject>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) discoverable: Option<bool>,
#[serde(default)]
pub(crate) manually_approves_followers: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub(crate) updated: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) endpoints: Option<Endpoints>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) image: Option<ApImageObject>,
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
pub(crate) also_known_as: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) attachment: Vec<ProfileFieldObject>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) featured: Option<Url>,
}

View File

@@ -2,6 +2,17 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use url::Url; use url::Url;
#[derive(Debug, Clone)]
pub struct LocalObject {
pub ap_id: Url,
pub object: serde_json::Value,
pub published_at: DateTime<Utc>,
pub to: Vec<String>,
pub cc: Vec<String>,
pub bto: Vec<String>,
pub bcc: Vec<String>,
}
/// Read side — the library queries this when sending content outward. /// Read side — the library queries this when sending content outward.
/// Implement on the same struct as [`ApObjectHandler`] if you prefer a single /// Implement on the same struct as [`ApObjectHandler`] if you prefer a single
/// database type. /// database type.
@@ -9,7 +20,6 @@ use url::Url;
pub trait ApContentReader: Send + Sync { pub trait ApContentReader: Send + Sync {
/// Newest-first page of locally-authored objects for `user_id`, published /// Newest-first page of locally-authored objects for `user_id`, published
/// strictly before `before` (pass `None` for the first page). /// strictly before `before` (pass `None` for the first page).
/// Returns `(ap_id, object_json, published_at)` tuples.
/// ///
/// Used by the outbox endpoint and by backfill when a new follower is /// Used by the outbox endpoint and by backfill when a new follower is
/// accepted. Implementations MUST: /// accepted. Implementations MUST:
@@ -21,7 +31,7 @@ pub trait ApContentReader: Send + Sync {
user_id: uuid::Uuid, user_id: uuid::Uuid,
before: Option<DateTime<Utc>>, before: Option<DateTime<Utc>>,
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>; ) -> anyhow::Result<Vec<LocalObject>>;
/// Total locally-authored posts across all users. Used by NodeInfo. /// Total locally-authored posts across all users. Used by NodeInfo.
async fn count_local_posts(&self) -> anyhow::Result<u64>; async fn count_local_posts(&self) -> anyhow::Result<u64>;
@@ -91,7 +101,7 @@ pub trait ApObjectHandler: Send + Sync {
/// A remote actor boosted (Announced) a **locally-authored** object. /// A remote actor boosted (Announced) a **locally-authored** object.
/// ///
/// `object_url` is your local object's AP URL. The boost count is tracked /// `object_url` is your local object's AP URL. The boost count is tracked
/// separately in [`crate::repository::ActorRepository::count_announces`]. /// separately in [`crate::repository::AnnounceRepository::count_announces`].
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// A remote actor removed their boost (`Undo(Announce)`) of a locally-authored /// A remote actor removed their boost (`Undo(Announce)`) of a locally-authored
@@ -124,4 +134,27 @@ pub trait ApObjectHandler: Send + Sync {
mentioned_user_uuid: uuid::Uuid, mentioned_user_uuid: uuid::Uuid,
actor_url: &Url, actor_url: &Url,
) -> anyhow::Result<()>; ) -> anyhow::Result<()>;
/// An inbound activity with an unrecognized type was received.
///
/// Override this to handle custom ActivityPub extensions (EmojiReact,
/// Question, Flag, etc.) that k-ap doesn't process natively.
/// The raw JSON and the sender's actor URL are provided.
///
/// **Note:** The default `router()` inbox handler gracefully accepts unknown
/// activity types but cannot dispatch to this method due to upstream library
/// constraints (the raw body is consumed during signature verification).
/// To fully handle unknown activities, build a custom inbox handler that
/// pre-parses the body before passing to `receive_activity`.
///
/// Default is a no-op — unknown activities are silently accepted.
async fn on_unknown_activity(
&self,
activity_type: &str,
activity: serde_json::Value,
actor_url: &Url,
) -> anyhow::Result<()> {
let _ = (activity_type, activity, actor_url);
Ok(())
}
} }

View File

@@ -4,6 +4,7 @@ use crate::content::{ApContentReader, ApObjectHandler};
use crate::repository::{ use crate::repository::{
ActivityRepository, ActorRepository, BlocklistRepository, FollowRepository, ActivityRepository, ActorRepository, BlocklistRepository, FollowRepository,
}; };
use crate::url_scheme::UrlScheme;
use crate::user::ApUserRepository; use crate::user::ApUserRepository;
/// Typed event emitted by the federation layer. /// Typed event emitted by the federation layer.
@@ -71,6 +72,7 @@ pub struct FederationData {
pub(crate) software_name: String, pub(crate) software_name: String,
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>, pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
pub(crate) actor_cache_ttl: std::time::Duration, pub(crate) actor_cache_ttl: std::time::Duration,
pub(crate) url_scheme: Arc<dyn UrlScheme>,
pub(crate) nodeinfo_services_inbound: Vec<String>, pub(crate) nodeinfo_services_inbound: Vec<String>,
pub(crate) nodeinfo_services_outbound: Vec<String>, pub(crate) nodeinfo_services_outbound: Vec<String>,
pub(crate) nodeinfo_metadata: serde_json::Value, pub(crate) nodeinfo_metadata: serde_json::Value,
@@ -78,7 +80,7 @@ pub struct FederationData {
impl FederationData { impl FederationData {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn new( pub(crate) fn new(
activity_repo: Arc<dyn ActivityRepository>, activity_repo: Arc<dyn ActivityRepository>,
follow_repo: Arc<dyn FollowRepository>, follow_repo: Arc<dyn FollowRepository>,
actor_repo: Arc<dyn ActorRepository>, actor_repo: Arc<dyn ActorRepository>,
@@ -91,6 +93,7 @@ impl FederationData {
software_name: String, software_name: String,
event_publisher: Option<Arc<dyn EventPublisher>>, event_publisher: Option<Arc<dyn EventPublisher>>,
actor_cache_ttl: std::time::Duration, actor_cache_ttl: std::time::Duration,
url_scheme: Arc<dyn UrlScheme>,
) -> Self { ) -> Self {
let domain = base_url let domain = base_url
.trim_start_matches("https://") .trim_start_matches("https://")
@@ -113,6 +116,7 @@ impl FederationData {
software_name, software_name,
event_publisher, event_publisher,
actor_cache_ttl, actor_cache_ttl,
url_scheme,
nodeinfo_services_inbound: vec![], nodeinfo_services_inbound: vec![],
nodeinfo_services_outbound: vec![], nodeinfo_services_outbound: vec![],
nodeinfo_metadata: serde_json::json!({}), nodeinfo_metadata: serde_json::json!({}),

View File

@@ -1,44 +1,70 @@
use std::fmt::{Display, Formatter};
use axum::http::StatusCode; use axum::http::StatusCode;
#[derive(Debug)] #[derive(Debug, thiserror::Error)]
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode); pub enum Error {
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
impl Error { impl Error {
pub fn not_found(e: impl Into<anyhow::Error>) -> Self { pub fn not_found(message: impl Into<String>) -> Self {
Self(e.into(), StatusCode::NOT_FOUND) Self::NotFound(message.into())
} }
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self { pub fn bad_request(message: impl Into<String>) -> Self {
Self(e.into(), StatusCode::BAD_REQUEST) Self::BadRequest(message.into())
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::Unauthorized(message.into())
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden(message.into())
} }
} }
impl Display for Error { impl From<activitypub_federation::error::Error> for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn from(error: activitypub_federation::error::Error) -> Self {
std::fmt::Display::fmt(&self.0, f) use activitypub_federation::error::Error as FedError;
}
}
impl<T> From<T> for Error match &error {
where FedError::ActivitySignatureInvalid | FedError::ActivityBodyDigestInvalid => {
T: Into<anyhow::Error>, Self::Unauthorized(error.to_string())
{ }
fn from(t: T) -> Self { _ => Self::Internal(error.into()),
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR) }
} }
} }
impl axum::response::IntoResponse for Error { impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
let status = self.1; let status = match &self {
// Always log the real error internally; never expose it to the client. Error::NotFound(_) => StatusCode::NOT_FOUND,
Error::BadRequest(_) => StatusCode::BAD_REQUEST,
Error::Unauthorized(_) => StatusCode::UNAUTHORIZED,
Error::Forbidden(_) => StatusCode::FORBIDDEN,
Error::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
if status.is_server_error() { if status.is_server_error() {
tracing::error!(error = %self.0, status = status.as_u16(), "federation error"); tracing::error!(error = %self, status = status.as_u16(), "federation error");
} else { } else {
tracing::debug!(error = %self.0, status = status.as_u16(), "federation client error"); tracing::debug!(error = %self, status = status.as_u16(), "federation client error");
} }
let body = match status { let body = match status {
StatusCode::NOT_FOUND => "not found", StatusCode::NOT_FOUND => "not found",
StatusCode::BAD_REQUEST => "bad request", StatusCode::BAD_REQUEST => "bad request",

View File

@@ -61,11 +61,15 @@ impl ApFederationConfig {
Ok(Self(config)) Ok(Self(config))
} }
fn inner(&self) -> &FederationConfig<FederationData> {
&self.0
}
pub fn to_request_data(&self) -> Data<FederationData> { pub fn to_request_data(&self) -> Data<FederationData> {
self.0.to_request_data() self.inner().to_request_data()
} }
pub fn middleware(&self) -> FederationMiddleware<FederationData> { pub fn middleware(&self) -> FederationMiddleware<FederationData> {
FederationMiddleware::new(self.0.clone()) FederationMiddleware::new(self.inner().clone())
} }
} }

View File

@@ -1,105 +0,0 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::{Path, Query};
use serde::Deserialize;
use serde_json::json;
use crate::data::FederationData;
use crate::error::Error;
use crate::urls::AP_PAGE_SIZE;
#[derive(Deserialize)]
pub struct PageQuery {
page: Option<u32>,
}
async fn collection_handler(
user_id_str: &str,
query: PageQuery,
data: Data<FederationData>,
collection_type: &str,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let collection_id = format!(
"{}/users/{}/{}",
data.base_url, user_id_str, collection_type
);
let total = match collection_type {
"followers" => data.follow_repo.count_followers(user_id).await,
_ => data.follow_repo.count_following(user_id).await,
}
.map_err(Error::from)?;
if let Some(page) = query.page {
let page = page.max(1);
let offset = (page.saturating_sub(1) as usize) * AP_PAGE_SIZE;
let items: Vec<String> = match collection_type {
"followers" => data
.follow_repo
.get_followers_page(user_id, offset as u32, AP_PAGE_SIZE)
.await
.map_err(Error::from)?
.into_iter()
.map(|f| f.actor.url)
.collect(),
_ => data
.follow_repo
.get_following_page(user_id, offset as u32, AP_PAGE_SIZE)
.await
.map_err(Error::from)?
.into_iter()
.map(|a| a.url)
.collect(),
};
let has_next = offset + items.len() < total;
let mut obj = json!({
"@context": crate::urls::AP_CONTEXT,
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_id, page),
"partOf": collection_id,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
}
Ok(FederationJson(obj))
} else {
Ok(FederationJson(json!({
"@context": crate::urls::AP_CONTEXT,
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})))
}
}
pub async fn followers_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "followers").await
}
pub async fn following_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "following").await
}

View File

@@ -14,8 +14,8 @@ pub async fn actor_handler(
Path(user_id_str): Path<String>, Path(user_id_str): Path<String>,
data: Data<FederationData>, data: Data<FederationData>,
) -> Result<FederationJson<WithContext<Person>>, Error> { ) -> Result<FederationJson<WithContext<Person>>, Error> {
let user_id = uuid::Uuid::parse_str(&user_id_str) let user_id =
.map_err(|_| Error::not_found(anyhow::anyhow!("user not found")))?; uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::not_found("user not found"))?;
let db_actor = get_local_actor(user_id, &data).await?; let db_actor = get_local_actor(user_id, &data).await?;
let person = db_actor.into_json(&data).await?; let person = db_actor.into_json(&data).await?;

View File

@@ -16,27 +16,22 @@ pub async fn featured_handler(
Path(user_id_str): Path<String>, Path(user_id_str): Path<String>,
data: Data<FederationData>, data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> { ) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(&user_id_str) let user_id =
.map_err(|_| Error::not_found(anyhow::anyhow!("user not found")))?; uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::not_found("user not found"))?;
data.user_repo data.user_repo
.find_by_id(user_id) .find_by_id(user_id)
.await .await?
.map_err(Error::from)? .ok_or_else(|| Error::not_found("user not found"))?;
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let featured_url = format!("{}/users/{}/featured", data.base_url, user_id_str); let featured_url = format!("{}/users/{}/featured", data.base_url, user_id_str);
let items = data let items = data.content_reader.get_featured_objects(user_id).await?;
.content_reader
.get_featured_objects(user_id)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
Ok(FederationJson(json!({ Ok(FederationJson(json!({
"@context": AP_CONTEXT, "@context": AP_CONTEXT,
"type": "OrderedCollection", "type": "OrderedCollection",
"id": featured_url, "id": featured_url,
"totalItems": items.len(), "totalItems": items.len(),
"orderedItems": items.iter().map(|u| u.as_str()).collect::<Vec<_>>(), "orderedItems": items.iter().map(|url| url.as_str()).collect::<Vec<_>>(),
}))) })))
} }

89
src/handlers/followers.rs Normal file
View File

@@ -0,0 +1,89 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::{Path, Query};
use serde::Deserialize;
use crate::data::FederationData;
use crate::error::Error;
use crate::service::collections::serialize_ordered_collection;
#[derive(Deserialize)]
pub struct PageQuery {
page: Option<u32>,
}
async fn collection_handler(
user_id_str: &str,
query: PageQuery,
data: Data<FederationData>,
collection_type: &str,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id =
uuid::Uuid::parse_str(user_id_str).map_err(|_| Error::bad_request("invalid user id"))?;
data.user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| Error::not_found("user not found"))?;
let actor_url = data
.url_scheme
.actor_url(&data.base_url, user_id)
.map_err(Error::from)?;
let collection_url = match collection_type {
"followers" => data.url_scheme.followers_url(&actor_url),
_ => data.url_scheme.following_url(&actor_url),
}
.map_err(Error::from)?
.to_string();
let total = match collection_type {
"followers" => data.follow_repo.count_followers(user_id).await,
_ => data.follow_repo.count_following(user_id).await,
}
.map_err(Error::from)?;
let items_fn = |offset: u32, limit: usize| {
let data = data.clone();
async move {
Ok(match collection_type {
"followers" => data
.follow_repo
.get_followers_page(user_id, offset, limit)
.await?
.into_iter()
.map(|follower| follower.actor.url)
.collect(),
_ => data
.follow_repo
.get_following_page(user_id, offset, limit)
.await?
.into_iter()
.map(|actor| actor.url)
.collect(),
})
}
};
let json_str = serialize_ordered_collection(&collection_url, total, query.page, items_fn)
.await
.map_err(Error::from)?;
let value: serde_json::Value =
serde_json::from_str(&json_str).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok(FederationJson(value))
}
pub async fn followers_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "followers").await
}
pub async fn following_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "following").await
}

35
src/handlers/inbox.rs Normal file
View File

@@ -0,0 +1,35 @@
use activitypub_federation::{
axum::inbox::{ActivityData, receive_activity},
config::Data,
protocol::context::WithContext,
};
use crate::activities::InboxActivities;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
pub async fn inbox_handler(
data: Data<FederationData>,
activity_data: ActivityData,
) -> Result<(), Error> {
let result = receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(
activity_data,
&data,
)
.await;
match result {
Ok(()) => Ok(()),
Err(Error::Internal(ref inner)) if is_unknown_activity_error(inner) => {
tracing::debug!(error = %inner, "unknown activity type, accepted without processing");
Ok(())
}
Err(error) => Err(error),
}
}
fn is_unknown_activity_error(error: &anyhow::Error) -> bool {
let message = error.to_string();
message.contains("unknown variant") || message.contains("does not match any variant")
}

7
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod actor;
pub mod featured;
pub mod followers;
pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod webfinger;

162
src/handlers/outbox.rs Normal file
View File

@@ -0,0 +1,162 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
protocol::context::WithContext,
};
use crate::{
activities::CreateActivity, content::LocalObject, data::FederationData, error::Error,
urls::AP_PAGE_SIZE,
};
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollection {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
total_items: u64,
first: String,
last: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
total_items: u64,
ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
}
pub async fn outbox_handler(
Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let uuid =
uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::bad_request("invalid user id"))?;
data.user_repo
.find_by_id(uuid)
.await?
.ok_or_else(|| Error::not_found("user not found"))?;
let actor_url = data.url_scheme.actor_url(&data.base_url, uuid)?;
let outbox_url = data.url_scheme.outbox_url(&actor_url)?.to_string();
let total = data.content_reader.count_local_posts().await?;
if query.page.unwrap_or(false) {
build_outbox_page(uuid, &query, &outbox_url, total, &data).await
} else {
build_outbox_collection(&outbox_url, total)
}
}
async fn build_outbox_page(
user_id: uuid::Uuid,
query: &OutboxQuery,
outbox_url: &str,
total: u64,
data: &Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data
.content_reader
.get_local_objects_page(user_id, before, AP_PAGE_SIZE)
.await?;
let actor_url: Url = data
.url_scheme
.actor_url(&data.base_url, user_id)
.map_err(|error| Error::bad_request(format!("invalid base_url: {error}")))?;
let has_more = items.len() == AP_PAGE_SIZE;
let oldest_timestamp = items.last().map(|item| item.published_at);
let ordered_items = wrap_items_as_create_activities(&items, &actor_url)?;
let page_id = match &query.before {
Some(before) => format!("{}?page=true&before={}", outbox_url, before),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_timestamp.map(|timestamp| {
let formatted = timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
format!("{}?page=true&before={}", outbox_url, formatted)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url.to_string(),
total_items: total,
ordered_items,
next,
})
.into_response())
}
fn build_outbox_collection(
outbox_url: &str,
total: u64,
) -> Result<axum::response::Response, Error> {
Ok(axum::Json(OrderedCollection {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.to_string(),
total_items: total,
first: format!("{}?page=true", outbox_url),
last: format!("{}?page=true&before=1970-01-01T00:00:00.000Z", outbox_url),
})
.into_response())
}
fn wrap_items_as_create_activities(
items: &[LocalObject],
actor_url: &Url,
) -> Result<Vec<serde_json::Value>, Error> {
items
.iter()
.map(|item| {
let create_id = Url::parse(&format!("{}/activity", item.ap_id))
.map_err(|error| anyhow::anyhow!(error))?;
let activity = WithContext::new_default(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object: item.object.clone(),
to: item.to.clone(),
cc: item.cc.clone(),
bto: vec![],
bcc: vec![],
});
serde_json::to_value(activity).map_err(|error| anyhow::anyhow!(error).into())
})
.collect()
}

View File

@@ -40,14 +40,13 @@ pub async fn webfinger_handler(
let user = data let user = data
.user_repo .user_repo
.find_by_username(name) .find_by_username(name)
.await .await?
.map_err(Error::from)? .ok_or_else(|| Error::not_found("user not found"))?;
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let ap_id = crate::urls::actor_url(&data.base_url, user.id); let ap_id = data.url_scheme.actor_url(&data.base_url, user.id)?;
let acct_uri = format!("acct:{}@{}", user.username, data.domain); let acct_uri = format!("acct:{}@{}", user.username, data.domain);
let wf = WebfingerResponse { let response = WebfingerResponse {
subject: query.resource.clone(), subject: query.resource.clone(),
aliases: vec![acct_uri, ap_id.to_string()], aliases: vec![acct_uri, ap_id.to_string()],
links: vec![ links: vec![
@@ -58,12 +57,12 @@ pub async fn webfinger_handler(
}, },
WebfingerLink { WebfingerLink {
rel: "self".to_string(), rel: "self".to_string(),
kind: Some("application/activity+json".to_string()), kind: Some(crate::urls::AP_CONTENT_TYPE.to_string()),
href: Some(ap_id.to_string()), href: Some(ap_id.to_string()),
}, },
], ],
}; };
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?; let body = serde_json::to_string(&response).map_err(|error| anyhow::anyhow!(error))?;
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response()) Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
} }

View File

@@ -1,23 +0,0 @@
use activitypub_federation::{
axum::inbox::{ActivityData, receive_activity},
config::Data,
protocol::context::WithContext,
};
use crate::activities::InboxActivities;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
/// Idempotency is enforced inside each activity's `receive()` implementation
/// via `FederationRepository::is_activity_processed` /
/// `mark_activity_processed`. HTTP signature verification and JSON-LD
/// processing are handled by `activitypub_federation` middleware before this
/// handler is reached.
pub async fn inbox_handler(
data: Data<FederationData>,
activity_data: ActivityData,
) -> Result<(), Error> {
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
.await
}

View File

@@ -1,33 +1,35 @@
pub mod activities; pub(crate) mod activities;
pub mod actor_handler; pub(crate) mod actors;
pub mod actors; pub(crate) mod content;
pub mod content; pub(crate) mod data;
pub mod data; pub(crate) mod error;
pub mod error; pub(crate) mod federation;
pub mod featured_handler; pub(crate) mod handlers;
pub mod federation;
pub mod followers_handler;
pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod repository; pub mod repository;
pub(crate) mod security; pub(crate) mod security;
pub mod service; pub mod service;
/// Mock builders for testing. Not behind `#[cfg(test)]` so downstream crates
/// can use them in their own test suites.
pub mod testing;
pub(crate) mod url_scheme;
pub(crate) mod urls; pub(crate) mod urls;
pub mod user; pub(crate) mod user;
pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType; pub use content::{ApContentReader, ApObjectHandler, LocalObject};
pub use content::{ApContentReader, ApObjectHandler};
pub use data::{EventPublisher, FederationData, FederationEvent}; pub use data::{EventPublisher, FederationData, FederationEvent};
pub use error::Error; pub use error::Error;
pub use federation::ApFederationConfig; pub use federation::ApFederationConfig;
pub use handlers::actor::actor_handler;
pub use handlers::followers::{followers_handler, following_handler};
pub use repository::{ pub use repository::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, ActivityRepository, ActorBlocklist, ActorRepository, AnnounceRepository, BlockedDomain,
Follower, FollowerStatus, FollowingStatus, RemoteActor, BlocklistRepository, DomainBlocklist, FollowMigration, FollowRepository, Follower,
FollowerReader, FollowerStatus, FollowerWriter, FollowingReader, FollowingStatus,
FollowingWriter, Keypair, KeypairRepository, RemoteActor, RemoteActorCache,
}; };
pub use service::ActivityPubService; pub use service::ActivityPubService;
pub use urls::AS_PUBLIC; pub use url_scheme::{DefaultUrlScheme, UrlScheme};
pub use urls::{AP_CONTENT_TYPE, AP_CONTEXT, AS_PUBLIC, INBOX_BODY_LIMIT};
pub use user::{ pub use user::{
ApActorType, ApProfileField, ApUser, ApUserRepository, ApVisibility, LookedUpActor, ApActorType, ApProfileField, ApUser, ApUserRepository, ApVisibility, LookedUpActor,
}; };

View File

@@ -1,145 +0,0 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
protocol::context::WithContext,
};
use crate::{activities::CreateActivity, data::FederationData, error::Error, urls::AP_PAGE_SIZE};
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollection {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
total_items: u64,
first: String,
last: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
total_items: u64,
ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
}
pub async fn outbox_handler(
Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let uuid = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(uuid)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
// Total count — uses count_local_posts for an aggregated count. For a
// per-user count we use the page length on the first page as an upper bound
// if count_local_posts returns 0. In practice this trait method is called
// infrequently (only on the root collection endpoint).
let total = data
.content_reader
.count_local_posts()
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
if query.page.unwrap_or(false) {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data
.content_reader
.get_local_objects_page(uuid, before, AP_PAGE_SIZE)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
.parse()
.expect("valid url");
let has_more = items.len() == AP_PAGE_SIZE;
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
let followers_url = format!("{}/followers", actor_url);
let ordered_items: Vec<serde_json::Value> = items
.into_iter()
.map(|(ap_id, object, _)| {
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
serde_json::to_value(WithContext::new_default(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![followers_url.clone()],
bto: vec![],
bcc: vec![],
}))
.expect("serializable")
})
.collect();
let page_id = match &query.before {
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_ts.map(|ts| {
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
format!("{}?page=true&before={}", outbox_url, ts_str)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url,
total_items: total,
ordered_items,
next,
})
.into_response())
} else {
Ok(axum::Json(OrderedCollection {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.clone(),
total_items: total,
first: format!("{}?page=true", outbox_url),
last: format!("{}?page=true&before=1970-01-01T00:00:00.000Z", outbox_url),
})
.into_response())
}
}

View File

@@ -1,37 +1,5 @@
use anyhow::Result; use super::{AnnounceRepository, KeypairRepository, RemoteActorCache};
use async_trait::async_trait;
use super::RemoteActor;
/// Manages local actor keypairs, remote actor cache, and Announce tracking. /// Manages local actor keypairs, remote actor cache, and Announce tracking.
#[async_trait] pub trait ActorRepository: KeypairRepository + RemoteActorCache + AnnounceRepository {}
pub trait ActorRepository: Send + Sync { impl<T: KeypairRepository + RemoteActorCache + AnnounceRepository> ActorRepository for T {}
// ── Local keypairs ──────────────────────────────────────────────────────
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
// ── Remote actor cache ──────────────────────────────────────────────────
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
// ── Boost (Announce) tracking ───────────────────────────────────────────
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
/// Remove a boost record when a remote actor sends `Undo(Announce)`.
/// Implementations should match by `activity_id` and `actor_url`.
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
}

View File

@@ -0,0 +1,10 @@
use anyhow::Result;
use async_trait::async_trait;
#[async_trait]
pub trait ActorBlocklist: Send + Sync {
async fn add_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 is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
}

View File

@@ -0,0 +1,17 @@
use anyhow::Result;
use async_trait::async_trait;
#[async_trait]
pub trait AnnounceRepository: Send + Sync {
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
/// Remove a boost record when a remote actor sends `Undo(Announce)`.
/// Implementations should match by `activity_id` and `actor_url`.
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
}

View File

@@ -1,20 +1,5 @@
use anyhow::Result; use super::{ActorBlocklist, DomainBlocklist};
use async_trait::async_trait;
use super::BlockedDomain;
/// Domain and actor-level blocklists. /// Domain and actor-level blocklists.
#[async_trait] pub trait BlocklistRepository: DomainBlocklist + ActorBlocklist {}
pub trait BlocklistRepository: Send + Sync { impl<T: DomainBlocklist + ActorBlocklist> BlocklistRepository for T {}
// ── Domain blocklist ────────────────────────────────────────────────────
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
// ── Per-user actor blocklist ────────────────────────────────────────────
async fn add_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 is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
}

View File

@@ -0,0 +1,12 @@
use anyhow::Result;
use async_trait::async_trait;
use super::types::BlockedDomain;
#[async_trait]
pub trait DomainBlocklist: Send + Sync {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
}

View File

@@ -1,98 +1,11 @@
use anyhow::Result; use super::{FollowMigration, FollowerReader, FollowerWriter, FollowingReader, FollowingWriter};
use async_trait::async_trait;
use super::{Follower, FollowerStatus, FollowingStatus, RemoteActor};
/// Manages follower/following relationships and account migration. /// Manages follower/following relationships and account migration.
#[async_trait] pub trait FollowRepository:
pub trait FollowRepository: Send + Sync { FollowerWriter + FollowerReader + FollowingWriter + FollowingReader + FollowMigration
// ── Inbound followers ─────────────────────────────────────────────────── {
async fn add_follower( }
&self, impl<T: FollowerWriter + FollowerReader + FollowingWriter + FollowingReader + FollowMigration>
local_user_id: uuid::Uuid, FollowRepository for T
remote_actor_url: &str, {
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()>;
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
async fn get_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>>;
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
/// Return deduplicated inbox URLs (shared_inbox preferred) for accepted
/// followers, excluding blocked actors/domains. DB-side filtering.
async fn get_accepted_follower_inboxes(&self, local_user_id: uuid::Uuid)
-> Result<Vec<String>>;
/// Count of accepted followers only. More efficient than loading all followers
/// and filtering in application memory.
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
/// Accepted followers page for display purposes. `offset` is 0-based.
async fn get_accepted_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
// ── Outbound following ──────────────────────────────────────────────────
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
// ── Account migration ───────────────────────────────────────────────────
/// Migrate all follower records from `old_actor_url` to `new_actor_url`.
/// Returns local user IDs that need a re-follow sent.
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>>;
} }

View File

@@ -0,0 +1,26 @@
use anyhow::Result;
use async_trait::async_trait;
/// Handles account migration by remapping follower records from one actor URL
/// to another.
///
/// Used by:
/// - `activities/move_act.rs` (Move activity processing)
///
/// Most implementations can use the provided default no-op if account
/// migration is not supported.
#[async_trait]
pub trait FollowMigration: Send + Sync {
/// Migrate all follower records from `old_actor_url` to `new_actor_url`.
/// Returns local user IDs that need a re-follow sent.
///
/// The default implementation is a no-op returning an empty list, suitable
/// for deployments that do not support account migration.
async fn migrate_follower_actor(
&self,
_old_actor_url: &str,
_new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
Ok(vec![])
}
}

View File

@@ -0,0 +1,38 @@
use anyhow::Result;
use async_trait::async_trait;
use super::types::{Follower, RemoteActor};
/// Read-only view of follower relationships.
///
/// Used by:
/// - `ActivityPubService::accepted_follower_inboxes` (via `get_accepted_follower_inboxes`)
/// - `service/collections.rs` (via `count_followers`, `get_followers_page`)
/// - `handlers/followers.rs` (via `count_followers`, `get_followers_page`)
/// - `service/broadcast.rs` (via `get_accepted_follower_inboxes`)
#[async_trait]
pub trait FollowerReader: Send + Sync {
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
async fn get_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>>;
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
/// Return deduplicated inbox URLs (shared_inbox preferred) for accepted
/// followers, excluding blocked actors/domains. DB-side filtering.
async fn get_accepted_follower_inboxes(&self, local_user_id: uuid::Uuid)
-> Result<Vec<String>>;
/// Count of accepted followers only. More efficient than loading all followers
/// and filtering in application memory.
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
/// Accepted followers page for display purposes. `offset` is 0-based.
async fn get_accepted_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
}

View File

@@ -0,0 +1,36 @@
use anyhow::Result;
use async_trait::async_trait;
use super::types::FollowerStatus;
/// Write operations for follower relationships.
///
/// Used by:
/// - inbox handlers (Accept/Follow/Undo processing)
/// - `service/lookup.rs` (via `update_follower_status`, `remove_follower`)
#[async_trait]
pub trait FollowerWriter: Send + Sync {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()>;
}

View File

@@ -0,0 +1,21 @@
use anyhow::Result;
use async_trait::async_trait;
use super::types::RemoteActor;
/// Read-only view of following relationships (accounts this user follows).
///
/// Used by:
/// - `service/collections.rs` (via `count_following`, `get_following_page`)
/// - `handlers/followers.rs` (via `count_following`, `get_following_page`)
#[async_trait]
pub trait FollowingReader: Send + Sync {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
}

View File

@@ -0,0 +1,30 @@
use anyhow::Result;
use async_trait::async_trait;
use super::types::{FollowingStatus, RemoteActor};
/// Write operations for following relationships (outbound follows).
///
/// Used by:
/// - `service/follow.rs` (follow/unfollow/accept processing)
#[async_trait]
pub trait FollowingWriter: Send + Sync {
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
}

10
src/repository/keypair.rs Normal file
View File

@@ -0,0 +1,10 @@
use anyhow::Result;
use async_trait::async_trait;
use super::types::Keypair;
#[async_trait]
pub trait KeypairRepository: Send + Sync {
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>>;
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()>;
}

View File

@@ -1,57 +1,31 @@
mod activity; mod activity;
mod actor; mod actor;
mod actor_blocklist;
mod announce;
mod blocklist; mod blocklist;
mod domain_blocklist;
mod follow; mod follow;
mod follow_migration;
mod follower_reader;
mod follower_writer;
mod following_reader;
mod following_writer;
mod keypair;
mod remote_actor_cache;
mod types;
pub use activity::ActivityRepository; pub use activity::ActivityRepository;
pub use actor::ActorRepository; pub use actor::ActorRepository;
pub use actor_blocklist::ActorBlocklist;
pub use announce::AnnounceRepository;
pub use blocklist::BlocklistRepository; pub use blocklist::BlocklistRepository;
pub use domain_blocklist::DomainBlocklist;
pub use follow::FollowRepository; pub use follow::FollowRepository;
pub use follow_migration::FollowMigration;
use chrono::{DateTime, Utc}; pub use follower_reader::FollowerReader;
pub use follower_writer::FollowerWriter;
#[derive(Debug, Clone, PartialEq, Eq)] pub use following_reader::FollowingReader;
pub enum FollowerStatus { pub use following_writer::FollowingWriter;
Pending, pub use keypair::KeypairRepository;
Accepted, pub use remote_actor_cache::RemoteActorCache;
Rejected, pub use types::{BlockedDomain, Follower, FollowerStatus, FollowingStatus, Keypair, RemoteActor};
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub outbox_url: Option<String>,
pub bio: Option<String>,
pub banner_url: Option<String>,
pub followers_url: Option<String>,
pub following_url: Option<String>,
pub also_known_as: Vec<String>,
/// When this actor was last fetched from the origin instance.
/// `None` means unknown — treated as always-fresh to avoid
/// breaking existing consumers that don't populate this field.
pub fetched_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct Follower {
pub actor: RemoteActor,
pub status: FollowerStatus,
}
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}

View File

@@ -0,0 +1,10 @@
use anyhow::Result;
use async_trait::async_trait;
use super::types::RemoteActor;
#[async_trait]
pub trait RemoteActorCache: Send + Sync {
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
}

121
src/repository/types.rs Normal file
View File

@@ -0,0 +1,121 @@
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus {
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub outbox_url: Option<String>,
pub bio: Option<String>,
pub banner_url: Option<String>,
pub followers_url: Option<String>,
pub following_url: Option<String>,
pub also_known_as: Vec<String>,
/// When this actor was last fetched from the origin instance.
/// `None` means unknown — treated as always-fresh to avoid
/// breaking existing consumers that don't populate this field.
pub fetched_at: Option<DateTime<Utc>>,
}
impl From<&crate::actors::DbActor> for RemoteActor {
fn from(actor: &crate::actors::DbActor) -> Self {
Self {
url: actor.ap_id.to_string(),
handle: format!(
"{}@{}",
actor.username,
actor.ap_id.host_str().unwrap_or("")
),
inbox_url: actor.inbox_url.to_string(),
shared_inbox_url: actor.shared_inbox_url.as_ref().map(|url| url.to_string()),
display_name: actor
.display_name
.clone()
.or_else(|| Some(actor.username.clone())),
avatar_url: actor.avatar_url.as_ref().map(|url| url.to_string()),
outbox_url: Some(actor.outbox_url.to_string()),
bio: actor.bio.clone(),
banner_url: actor.banner_url.as_ref().map(|url| url.to_string()),
followers_url: Some(actor.followers_url.to_string()),
following_url: Some(actor.following_url.to_string()),
also_known_as: actor.also_known_as.clone(),
fetched_at: Some(Utc::now()),
}
}
}
impl RemoteActor {
pub fn from_ap_person(person: &crate::actors::Person) -> Self {
Self {
url: person.id.inner().to_string(),
handle: person.preferred_username.clone(),
inbox_url: person.inbox.to_string(),
shared_inbox_url: person
.endpoints
.as_ref()
.map(|endpoints| endpoints.shared_inbox.to_string()),
display_name: person.name.clone(),
avatar_url: person.icon.as_ref().map(|icon| icon.url.to_string()),
outbox_url: person.outbox.as_ref().map(|url| url.to_string()),
bio: person.summary.clone(),
banner_url: person.image.as_ref().map(|image| image.url.to_string()),
followers_url: person.followers.as_ref().map(|url| url.to_string()),
following_url: person.following.as_ref().map(|url| url.to_string()),
also_known_as: person.also_known_as.clone(),
fetched_at: Some(Utc::now()),
}
}
pub fn placeholder(actor_url: String) -> Self {
Self {
handle: actor_url.clone(),
inbox_url: actor_url.clone(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
outbox_url: None,
bio: None,
banner_url: None,
followers_url: None,
following_url: None,
also_known_as: vec![],
fetched_at: None,
url: actor_url,
}
}
}
#[derive(Debug, Clone)]
pub struct Follower {
pub actor: RemoteActor,
pub status: FollowerStatus,
}
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[derive(Debug, Clone)]
pub struct Keypair {
pub public_key: String,
pub private_key: String,
}

View File

@@ -1,132 +0,0 @@
use std::net::IpAddr;
use url::Url;
fn is_ip_private(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 (ULA)
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 (link-local)
}
}
}
/// Resolve a URL's hostname and reject private/reserved IP ranges.
pub(crate) async fn validate_url(url: &Url) -> anyhow::Result<()> {
let host = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("URL has no host: {url}"))?;
let port = url.port_or_known_default().unwrap_or(443);
let addr = format!("{host}:{port}");
let resolved = tokio::net::lookup_host(&addr).await?;
for ip in resolved {
if is_ip_private(ip.ip()) {
anyhow::bail!("SSRF blocked: {url} resolves to private IP {}", ip.ip());
}
}
Ok(())
}
#[derive(Clone)]
pub(crate) struct SsrfVerifier;
#[async_trait::async_trait]
impl activitypub_federation::config::UrlVerifier for SsrfVerifier {
async fn verify(&self, url: &Url) -> Result<(), activitypub_federation::error::Error> {
validate_url(url).await.map_err(|_| {
activitypub_federation::error::Error::UrlVerificationError(
"URL resolves to a private/reserved IP range",
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_ipv4_loopback() {
assert!(is_ip_private("127.0.0.1".parse().unwrap()));
assert!(is_ip_private("127.255.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_10() {
assert!(is_ip_private("10.0.0.1".parse().unwrap()));
assert!(is_ip_private("10.255.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_172() {
assert!(is_ip_private("172.16.0.1".parse().unwrap()));
assert!(is_ip_private("172.31.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_192() {
assert!(is_ip_private("192.168.0.1".parse().unwrap()));
assert!(is_ip_private("192.168.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_link_local() {
assert!(is_ip_private("169.254.0.1".parse().unwrap()));
assert!(is_ip_private("169.254.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_unspecified() {
assert!(is_ip_private("0.0.0.0".parse().unwrap()));
}
#[test]
fn rejects_ipv4_cgnat() {
assert!(is_ip_private("100.64.0.1".parse().unwrap()));
assert!(is_ip_private("100.127.255.255".parse().unwrap()));
}
#[test]
fn allows_public_ipv4() {
assert!(!is_ip_private("8.8.8.8".parse().unwrap()));
assert!(!is_ip_private("1.1.1.1".parse().unwrap()));
assert!(!is_ip_private("93.184.216.34".parse().unwrap()));
}
#[test]
fn rejects_ipv6_loopback() {
assert!(is_ip_private("::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_unspecified() {
assert!(is_ip_private("::".parse().unwrap()));
}
#[test]
fn rejects_ipv6_ula() {
assert!(is_ip_private("fc00::1".parse().unwrap()));
assert!(is_ip_private("fd12:3456::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_link_local() {
assert!(is_ip_private("fe80::1".parse().unwrap()));
}
#[test]
fn allows_public_ipv6() {
assert!(!is_ip_private("2001:4860:4860::8888".parse().unwrap()));
assert!(!is_ip_private("2606:4700::1111".parse().unwrap()));
}
}

70
src/security/mod.rs Normal file
View File

@@ -0,0 +1,70 @@
use std::net::{IpAddr, Ipv4Addr};
use url::Url;
fn is_ipv4_private(v4: Ipv4Addr) -> bool {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGNAT 100.64.0.0/10
|| v4.octets()[0] == 0 // 0.0.0.0/8 "this network"
|| (v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 2) // TEST-NET-1
|| (v4.octets()[0] == 198 && v4.octets()[1] == 51 && v4.octets()[2] == 100) // TEST-NET-2
|| (v4.octets()[0] == 203 && v4.octets()[1] == 0 && v4.octets()[2] == 113) // TEST-NET-3
|| (v4.octets()[0] == 198 && (v4.octets()[1] & 0xFE) == 18) // benchmarking 198.18.0.0/15
|| v4.octets()[0] >= 240 // reserved 240.0.0.0/4
}
fn is_ip_private(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_ipv4_private(v4),
IpAddr::V6(v6) => {
if let Some(mapped_v4) = v6.to_ipv4_mapped() {
return is_ipv4_private(mapped_v4);
}
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // ULA fc00::/7
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10
|| (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8) // documentation 2001:db8::/32
}
}
}
/// Resolve a URL's hostname and reject private/reserved IP ranges.
pub(crate) async fn validate_url(url: &Url) -> anyhow::Result<()> {
let host = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("URL has no host: {url}"))?;
let port = url.port_or_known_default().unwrap_or(443);
let addr = format!("{host}:{port}");
let resolved = tokio::net::lookup_host(&addr).await?;
for ip in resolved {
if is_ip_private(ip.ip()) {
anyhow::bail!("SSRF blocked: {url} resolves to private IP {}", ip.ip());
}
}
Ok(())
}
#[derive(Clone)]
pub(crate) struct SsrfVerifier;
#[async_trait::async_trait]
impl activitypub_federation::config::UrlVerifier for SsrfVerifier {
async fn verify(&self, url: &Url) -> Result<(), activitypub_federation::error::Error> {
validate_url(url).await.map_err(|_| {
activitypub_federation::error::Error::UrlVerificationError(
"URL resolves to a private/reserved IP range",
)
})
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;

115
src/security/tests.rs Normal file
View File

@@ -0,0 +1,115 @@
use super::*;
#[test]
fn rejects_ipv4_loopback() {
assert!(is_ip_private("127.0.0.1".parse().unwrap()));
assert!(is_ip_private("127.255.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_10() {
assert!(is_ip_private("10.0.0.1".parse().unwrap()));
assert!(is_ip_private("10.255.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_172() {
assert!(is_ip_private("172.16.0.1".parse().unwrap()));
assert!(is_ip_private("172.31.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_192() {
assert!(is_ip_private("192.168.0.1".parse().unwrap()));
assert!(is_ip_private("192.168.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_link_local() {
assert!(is_ip_private("169.254.0.1".parse().unwrap()));
assert!(is_ip_private("169.254.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_unspecified() {
assert!(is_ip_private("0.0.0.0".parse().unwrap()));
}
#[test]
fn rejects_ipv4_cgnat() {
assert!(is_ip_private("100.64.0.1".parse().unwrap()));
assert!(is_ip_private("100.127.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_test_net() {
assert!(is_ip_private("192.0.2.1".parse().unwrap()));
assert!(is_ip_private("198.51.100.1".parse().unwrap()));
assert!(is_ip_private("203.0.113.1".parse().unwrap()));
}
#[test]
fn rejects_ipv4_benchmarking() {
assert!(is_ip_private("198.18.0.1".parse().unwrap()));
assert!(is_ip_private("198.19.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_reserved() {
assert!(is_ip_private("240.0.0.1".parse().unwrap()));
assert!(is_ip_private("255.255.255.254".parse().unwrap()));
}
#[test]
fn allows_public_ipv4() {
assert!(!is_ip_private("8.8.8.8".parse().unwrap()));
assert!(!is_ip_private("1.1.1.1".parse().unwrap()));
assert!(!is_ip_private("93.184.216.34".parse().unwrap()));
}
#[test]
fn rejects_ipv6_loopback() {
assert!(is_ip_private("::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_unspecified() {
assert!(is_ip_private("::".parse().unwrap()));
}
#[test]
fn rejects_ipv6_ula() {
assert!(is_ip_private("fc00::1".parse().unwrap()));
assert!(is_ip_private("fd12:3456::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_link_local() {
assert!(is_ip_private("fe80::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_documentation() {
assert!(is_ip_private("2001:db8::1".parse().unwrap()));
assert!(is_ip_private("2001:db8:ffff::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_mapped_private_ipv4() {
assert!(is_ip_private("::ffff:10.0.0.1".parse().unwrap()));
assert!(is_ip_private("::ffff:127.0.0.1".parse().unwrap()));
assert!(is_ip_private("::ffff:192.168.1.1".parse().unwrap()));
assert!(is_ip_private("::ffff:172.16.0.1".parse().unwrap()));
}
#[test]
fn allows_ipv6_mapped_public_ipv4() {
assert!(!is_ip_private("::ffff:8.8.8.8".parse().unwrap()));
assert!(!is_ip_private("::ffff:1.1.1.1".parse().unwrap()));
}
#[test]
fn allows_public_ipv6() {
assert!(!is_ip_private("2001:4860:4860::8888".parse().unwrap()));
assert!(!is_ip_private("2606:4700::1111".parse().unwrap()));
}

View File

@@ -1,6 +1,4 @@
use activitypub_federation::{ use activitypub_federation::{activity_sending::SendActivityTask, protocol::context::WithContext};
activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext,
};
use url::Url; use url::Url;
use crate::{activities::CreateActivity, actors::get_local_actor, federation::ApFederationConfig}; use crate::{activities::CreateActivity, actors::get_local_actor, federation::ApFederationConfig};
@@ -30,9 +28,10 @@ impl ActivityPubService {
.build()?; .build()?;
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let actor = url::Url::parse(actor_url)?; let actor = url::Url::parse(actor_url)?;
let root: serde_json::Value = client let root: serde_json::Value = client
.get(outbox_url) .get(outbox_url)
.header("Accept", "application/activity+json") .header("Accept", crate::urls::AP_CONTENT_TYPE)
.send() .send()
.await? .await?
.json() .json()
@@ -44,6 +43,7 @@ impl ActivityPubService {
return Ok(()); return Ok(());
} }
}; };
let mut current_url = first; let mut current_url = first;
let mut visited = std::collections::HashSet::new(); let mut visited = std::collections::HashSet::new();
loop { loop {
@@ -57,9 +57,10 @@ impl ActivityPubService {
tracing::warn!(url = %current_url, error = %e, "backfill: SSRF check failed"); tracing::warn!(url = %current_url, error = %e, "backfill: SSRF check failed");
break; break;
} }
let page: serde_json::Value = match client let page: serde_json::Value = match client
.get(&current_url) .get(&current_url)
.header("Accept", "application/activity+json") .header("Accept", crate::urls::AP_CONTENT_TYPE)
.send() .send()
.await .await
{ {
@@ -75,6 +76,7 @@ impl ActivityPubService {
break; break;
} }
}; };
if let Some(items) = page.get("orderedItems").and_then(|v| v.as_array()) { if let Some(items) = page.get("orderedItems").and_then(|v| v.as_array()) {
for item in items { for item in items {
let activity_type = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); let activity_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
@@ -96,11 +98,13 @@ impl ActivityPubService {
} }
} }
} }
match page.get("next").and_then(|v| v.as_str()) { match page.get("next").and_then(|v| v.as_str()) {
Some(next) => current_url = next.to_string(), Some(next) => current_url = next.to_string(),
None => break, None => break,
} }
} }
tracing::info!(outbox = %outbox_url, pages = visited.len(), "backfill complete"); tracing::info!(outbox = %outbox_url, pages = visited.len(), "backfill complete");
Ok(()) Ok(())
} }
@@ -150,7 +154,7 @@ impl ActivityPubService {
} }
/// Execute backfill for a single follower inbox. Call this from a job-queue /// Execute backfill for a single follower inbox. Call this from a job-queue
/// consumer that received a [`FederationEvent::BackfillRequested`] event. /// consumer that received a [`crate::data::FederationEvent::BackfillRequested`] event.
/// ///
/// Sends all of `owner_user_id`'s locally-authored content to `follower_inbox_url`, /// Sends all of `owner_user_id`'s locally-authored content to `follower_inbox_url`,
/// oldest-to-newest, with a small sleep between batches to avoid overwhelming /// oldest-to-newest, with a small sleep between batches to avoid overwhelming
@@ -181,13 +185,9 @@ impl ActivityPubService {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
const BATCH_SIZE: usize = 20; const BATCH_SIZE: usize = 20;
let data = config.to_request_data(); let data = config.to_request_data();
let local_actor = get_local_actor(owner_user_id, &data) let local_actor = get_local_actor(owner_user_id, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let inbox = Url::parse(&follower_inbox_url)?; let inbox = Url::parse(&follower_inbox_url)?;
// Cursor-based pagination via get_local_objects_page (newest-first).
// Avoids loading the entire post history into memory at once.
let mut before: Option<chrono::DateTime<chrono::Utc>> = None; let mut before: Option<chrono::DateTime<chrono::Utc>> = None;
let (mut success_count, mut failure_count, mut total) = (0usize, 0usize, 0usize); let (mut success_count, mut failure_count, mut total) = (0usize, 0usize, 0usize);
@@ -202,25 +202,25 @@ impl ActivityPubService {
} }
let is_last_page = page.len() < BATCH_SIZE; let is_last_page = page.len() < BATCH_SIZE;
// Advance cursor to the oldest timestamp in this page. before = page.last().map(|item| item.published_at);
before = page.last().map(|(_, _, ts)| *ts);
for (ap_id, object_json, _ts) in &page { for item in &page {
let create_id = Url::parse(&format!( let create_id = Url::parse(&format!(
"{}/activities/create/{}", "{}/activities/create/{}",
base_url, base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes()) uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, item.ap_id.as_str().as_bytes())
))?; ))?;
let create = CreateActivity { let create = CreateActivity {
id: create_id, id: create_id,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: object_json.clone(), object: item.object.clone(),
to: vec![], to: item.to.clone(),
cc: vec![], cc: item.cc.clone(),
bto: vec![], bto: vec![],
bcc: vec![], bcc: vec![],
}; };
let sends = SendActivityTask::prepare( let sends = SendActivityTask::prepare(
&WithContext::new_default(create), &WithContext::new_default(create),
&local_actor, &local_actor,
@@ -228,6 +228,7 @@ impl ActivityPubService {
&data, &data,
) )
.await?; .await?;
total += 1; total += 1;
if send_with_retry(sends, &data, max_attempts, initial_delay) if send_with_retry(sends, &data, max_attempts, initial_delay)
.await .await

View File

@@ -1,6 +1,4 @@
use activitypub_federation::{ use activitypub_federation::{protocol::context::WithContext, traits::Object};
fetch::object_id::ObjectId, protocol::context::WithContext, traits::Object,
};
use url::Url; use url::Url;
use crate::{ use crate::{
@@ -8,144 +6,138 @@ use crate::{
AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity, AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity,
UpdateActivity, UpdateActivity,
}, },
actors::get_local_actor, actors::{DbActor, get_local_actor},
urls::activity_url, data::FederationData,
user::ApVisibility, user::ApVisibility,
}; };
use super::ActivityPubService; use super::ActivityPubService;
use super::types::{AddRef, AddRefObject, AnnounceRef, LikeRef, TombstoneRef};
// Re-export so existing `crate::service::broadcast::{Addressing, visibility_addressing}` paths keep working.
#[allow(unused_imports)]
pub(crate) use super::types::Addressing;
pub(crate) use super::types::visibility_addressing;
fn deterministic_activity_id(
base_url: &str,
prefix: &str,
user_id: uuid::Uuid,
object_url: &Url,
) -> anyhow::Result<Url> {
let namespace_input = format!("{}/{}", user_id, object_url);
let deterministic_id =
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, namespace_input.as_bytes());
Ok(Url::parse(&format!(
"{}/activities/{}/{}",
base_url, prefix, deterministic_id
))?)
}
impl ActivityPubService { impl ActivityPubService {
pub async fn broadcast_announce_to_followers( pub async fn broadcast_announce_to_followers(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
object_ap_id: url::Url, object_ap_id: Url,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!( let announce_id =
"{}/activities/announce/{}", deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) = let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await? self.accepted_follower_inboxes(&data, local_user_id).await?
else { else {
return Ok(()); return Ok(());
}; };
let announce = AnnounceActivity { let announce = AnnounceActivity {
id: announce_id, id: announce_id,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: object_ap_id, object: object_ap_id,
published: Some(chrono::Utc::now()), published: Some(chrono::Utc::now()),
to: vec![crate::urls::AS_PUBLIC.to_string()], to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()], cc: vec![local_actor.followers_url.to_string()],
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, announce) self.send_activity(&data, &local_actor, inboxes, announce)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await .await
} }
pub async fn broadcast_undo_announce_to_followers( pub async fn broadcast_undo_announce_to_followers(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
object_ap_id: url::Url, object_ap_id: Url,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!( let announce_id =
"{}/activities/announce/{}", deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) = let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await? self.accepted_follower_inboxes(&data, local_user_id).await?
else { else {
return Ok(()); return Ok(());
}; };
let undo = UndoActivity { let undo = UndoActivity {
id: undo_id, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: serde_json::json!({"type":"Announce","id":announce_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}), object: serde_json::to_value(AnnounceRef {
kind: "Announce",
id: announce_id.to_string(),
actor: local_actor.ap_id.to_string(),
object: object_ap_id.to_string(),
})?,
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo) self.send_activity(&data, &local_actor, inboxes, undo).await
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
} }
pub async fn broadcast_like_to_inbox( pub async fn broadcast_like_to_inbox(
&self, &self,
liker_user_id: uuid::Uuid, liker_user_id: uuid::Uuid,
object_ap_id: url::Url, object_ap_id: Url,
author_inbox_url: url::Url, author_inbox_url: Url,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data) let local_actor = get_local_actor(liker_user_id, &data).await?;
.await let like_id =
.map_err(|e| anyhow::anyhow!("{e}"))?; deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes()
),
))?;
let like = crate::activities::LikeActivity { let like = crate::activities::LikeActivity {
id: like_id, id: like_id,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: object_ap_id, object: object_ap_id,
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], like) self.send_activity(&data, &local_actor, vec![author_inbox_url], like)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await .await
} }
pub async fn broadcast_undo_like_to_inbox( pub async fn broadcast_undo_like_to_inbox(
&self, &self,
liker_user_id: uuid::Uuid, liker_user_id: uuid::Uuid,
object_ap_id: url::Url, object_ap_id: Url,
author_inbox_url: url::Url, author_inbox_url: Url,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data) let local_actor = get_local_actor(liker_user_id, &data).await?;
.await let like_id =
.map_err(|e| anyhow::anyhow!("{e}"))?; deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes()
),
))?;
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = UndoActivity { let undo = UndoActivity {
id: undo_id, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: serde_json::json!({"type":"Like","id":like_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}), object: serde_json::to_value(LikeRef {
kind: "Like",
id: like_id.to_string(),
actor: local_actor.ap_id.to_string(),
object: object_ap_id.to_string(),
})?,
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], undo) self.send_activity(&data, &local_actor, vec![author_inbox_url], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await .await
} }
@@ -160,18 +152,20 @@ impl ActivityPubService {
else { else {
return Ok(()); return Ok(());
}; };
let delete = DeleteActivity { let delete = DeleteActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: serde_json::json!({"type": "Tombstone", "id": ap_id.to_string()}), object: serde_json::to_value(TombstoneRef {
kind: "Tombstone",
id: ap_id.to_string(),
})?,
to: vec![crate::urls::AS_PUBLIC.to_string()], to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()], cc: vec![local_actor.followers_url.to_string()],
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, delete) self.send_activity(&data, &local_actor, inboxes, delete)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await .await
} }
@@ -187,25 +181,23 @@ impl ActivityPubService {
else { else {
return Ok(()); return Ok(());
}; };
let add = AddActivity { let add = AddActivity {
id: ap_id, id: ap_id,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object, object,
to: vec![crate::urls::AS_PUBLIC.to_string()], to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()], cc: vec![local_actor.followers_url.to_string()],
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, add) self.send_activity(&data, &local_actor, inboxes, add).await
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
} }
pub async fn broadcast_undo_add_to_followers( pub async fn broadcast_undo_add_to_followers(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
watchlist_entry_ap_id: Url, object_ap_id: Url,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) = let Some((local_actor, inboxes)) =
@@ -213,153 +205,136 @@ impl ActivityPubService {
else { else {
return Ok(()); return Ok(());
}; };
let undo = UndoActivity { let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: serde_json::json!({"type":"Add","id":watchlist_entry_ap_id.as_str(),"object":{"id":watchlist_entry_ap_id.as_str()}}), object: serde_json::to_value(AddRef {
kind: "Add",
id: object_ap_id.to_string(),
object: AddRefObject {
id: object_ap_id.to_string(),
},
})?,
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo) self.send_activity(&data, &local_actor, inboxes, undo).await
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
} }
/// Fan out a Create(Note) activity to accepted followers and any explicitly /// Resolve the local actor, gather follower + mentioned inboxes, and compute
/// `to`/`cc` addressing. Returns `None` when visibility is `Private` or there
/// are no inboxes to deliver to.
async fn prepare_addressed_broadcast(
&self,
local_user_id: uuid::Uuid,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<
Option<(
activitypub_federation::config::Data<FederationData>,
DbActor,
Vec<Url>,
Addressing,
)>,
> {
if visibility == ApVisibility::Private {
return Ok(None);
}
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data).await?;
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let inboxes = merge_inboxes(follower_inboxes, mentioned_inboxes);
if inboxes.is_empty() {
return Ok(None);
}
let addressing = visibility_addressing(visibility, &local_actor.followers_url);
Ok(Some((data, local_actor, inboxes, addressing)))
}
/// Fan out a Create activity to accepted followers and any explicitly
/// mentioned actors. /// mentioned actors.
/// ///
/// `visibility` controls `to`/`cc` addressing and whether the note is public: /// `visibility` controls `to`/`cc` addressing:
/// - `Public` / `FollowersOnly`: delivered to followers + `mentioned_inboxes` /// - `Public` / `FollowersOnly`: delivered to followers + `mentioned_inboxes`
/// - `Private`: returns immediately — no delivery to anyone /// - `Private`: returns immediately — no delivery to anyone
/// ///
/// `mentioned_inboxes` should contain the inbox URLs of remote actors /// `mentioned_inboxes` should contain the inbox URLs of remote actors
/// explicitly tagged in the note who are not already followers. Resolve them /// explicitly tagged in the object who are not already followers. Resolve them
/// via [`ActivityPubService::lookup_actor_by_handle`] before calling. Pass an /// via [`ActivityPubService::lookup_actor_by_handle`] before calling. Pass an
/// empty `Vec` if there are no external mentions. /// empty `Vec` if there are no external mentions.
pub async fn broadcast_create_note( pub async fn broadcast_create(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
note: serde_json::Value, object: serde_json::Value,
visibility: ApVisibility, visibility: ApVisibility,
mentioned_inboxes: Vec<Url>, mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if visibility == ApVisibility::Private { let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
return Ok(()); return Ok(());
} };
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
// Merge follower inboxes with explicitly mentioned actor inboxes, let object_id_str = object["id"].as_str().unwrap_or("");
// deduplicating by string to avoid delivering the same inbox twice.
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let note_id_str = note["id"].as_str().unwrap_or("");
let create_id = Url::parse(&format!( let create_id = Url::parse(&format!(
"{}/activities/create/{}", "{}/activities/create/{}",
self.base_url, self.base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, note_id_str.as_bytes()) uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, object_id_str.as_bytes())
)) ))?;
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
let create = CreateActivity { let create = CreateActivity {
id: create_id, id: create_id,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: note, object,
to, to: addressing.to,
cc, cc: addressing.cc,
bto: vec![], bto: vec![],
bcc: vec![], bcc: vec![],
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, create) self.send_activity(&data, &local_actor, inboxes, create)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await .await
} }
/// Fan out an Update(Note) activity to accepted followers and mentioned actors. /// Fan out an Update activity to accepted followers and mentioned actors.
/// See [`broadcast_create_note`] for `mentioned_inboxes` semantics. /// See [`ActivityPubService::broadcast_create`] for `mentioned_inboxes` semantics.
pub async fn broadcast_update_note( pub async fn broadcast_update(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
note: serde_json::Value, object: serde_json::Value,
visibility: ApVisibility, visibility: ApVisibility,
mentioned_inboxes: Vec<Url>, mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if visibility == ApVisibility::Private { let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
return Ok(()); return Ok(());
}
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
let update = crate::activities::UpdateActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update) let update = UpdateActivity {
.await?; id: data.url_scheme.activity_url(&self.base_url)?,
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) kind: Default::default(),
actor: local_actor.object_id(),
object,
to: addressing.to,
cc: addressing.cc,
};
self.send_activity(&data, &local_actor, inboxes, update)
.await .await
} }
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<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data) let local_actor = get_local_actor(user_id, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?; let person = local_actor.clone().into_json(&data).await?;
let person = local_actor
.clone()
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person_json = let person_json =
serde_json::to_value(WithContext::new(person, crate::urls::actor_ap_context()))?; serde_json::to_value(WithContext::new(person, crate::urls::actor_ap_context()))?;
let update_id = Url::parse(&format!( let update_id = Url::parse(&format!(
@@ -370,65 +345,81 @@ impl ActivityPubService {
let update = UpdateActivity { let update = UpdateActivity {
id: update_id, id: update_id,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: person_json, object: person_json,
to: vec![crate::urls::AS_PUBLIC.to_string()], to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()], cc: vec![local_actor.followers_url.to_string()],
}; };
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else { let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "no accepted followers, skipping actor update broadcast"); tracing::info!(%user_id, "no accepted followers, skipping actor update broadcast");
return Ok(()); return Ok(());
}; };
tracing::info!(%user_id, inbox_count = inboxes.len(), "broadcasting actor update"); tracing::info!(%user_id, inbox_count = inboxes.len(), "broadcasting actor update");
let (json, sends, inboxes) = self self.send_activity(&data, &local_actor, inboxes, update)
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await .await
} }
pub async fn broadcast_move( pub async fn broadcast_move(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
new_actor_url: url::Url, new_actor_url: Url,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data) let local_actor = get_local_actor(user_id, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else { let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "broadcast_move: no accepted followers"); tracing::info!(%user_id, "broadcast_move: no accepted followers");
return Ok(()); return Ok(());
}; };
let move_activity = MoveActivity { let move_activity = MoveActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: local_actor.ap_id.clone(), object: local_actor.ap_id.clone(),
target: new_actor_url.clone(), target: new_actor_url.clone(),
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, move_activity) self.send_activity(&data, &local_actor, inboxes, move_activity)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?; .await?;
tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched"); tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched");
Ok(()) Ok(())
} }
} /// Broadcast a pre-built activity to all accepted followers.
///
/// This is the low-level escape hatch for custom activity types that
/// k-ap doesn't have a dedicated method for. The `activity` JSON must
/// be a complete AP activity with `id`, `type`, `actor`, etc. already set.
/// k-ap wraps it in `@context` and handles signing + delivery.
pub async fn broadcast_raw_to_followers(
&self,
local_user_id: uuid::Uuid,
activity: 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(());
};
/// Returns `(to, cc)` addressing for the given visibility. self.send_raw_activity(&data, &local_actor, inboxes, activity)
/// `Private` is handled before calling this (early return in broadcast methods). .await
pub(crate) fn visibility_addressing(
visibility: ApVisibility,
followers_url: &Url,
) -> (Vec<String>, Vec<String>) {
match visibility {
ApVisibility::Public => (
vec![crate::urls::AS_PUBLIC.to_string()],
vec![followers_url.to_string()],
),
ApVisibility::FollowersOnly => (vec![followers_url.to_string()], vec![]),
ApVisibility::Private => (vec![], vec![]),
} }
} }
fn merge_inboxes(follower_inboxes: Vec<String>, mentioned_inboxes: Vec<Url>) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|inbox_str| Url::parse(&inbox_str).ok())
.filter(|url| seen.insert(url.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
inboxes
}

219
src/service/builder.rs Normal file
View File

@@ -0,0 +1,219 @@
use std::sync::Arc;
use crate::{
content::{ApContentReader, ApObjectHandler},
data::FederationData,
federation::ApFederationConfig,
repository::{ActivityRepository, ActorRepository, BlocklistRepository, FollowRepository},
url_scheme::{DefaultUrlScheme, UrlScheme},
user::ApUserRepository,
};
use super::{
ACTOR_CACHE_TTL_SECS, ActivityPubService, DELIVERY_INITIAL_DELAY_SECS, DELIVERY_MAX_ATTEMPTS,
};
pub struct ActivityPubServiceBuilder {
activity_repo: Option<Arc<dyn ActivityRepository>>,
follow_repo: Option<Arc<dyn FollowRepository>>,
actor_repo: Option<Arc<dyn ActorRepository>>,
blocklist_repo: Option<Arc<dyn BlocklistRepository>>,
user_repo: Option<Arc<dyn ApUserRepository>>,
content_reader: Option<Arc<dyn ApContentReader>>,
object_handler: Option<Arc<dyn ApObjectHandler>>,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
delivery_max_attempts: u32,
delivery_initial_delay_secs: u64,
signed_fetch_actor_id: Option<uuid::Uuid>,
actor_cache_ttl_secs: u64,
url_scheme: Option<Arc<dyn UrlScheme>>,
nodeinfo_services_inbound: Vec<String>,
nodeinfo_services_outbound: Vec<String>,
nodeinfo_metadata: serde_json::Value,
}
impl ActivityPubServiceBuilder {
pub(super) fn new(base_url: String) -> Self {
Self {
activity_repo: None,
follow_repo: None,
actor_repo: None,
blocklist_repo: None,
user_repo: None,
content_reader: None,
object_handler: None,
base_url,
allow_registration: false,
software_name: String::new(),
debug: false,
event_publisher: None,
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
signed_fetch_actor_id: None,
actor_cache_ttl_secs: ACTOR_CACHE_TTL_SECS,
url_scheme: None,
nodeinfo_services_inbound: vec![],
nodeinfo_services_outbound: vec![],
nodeinfo_metadata: serde_json::json!({}),
}
}
pub fn activity_repo(mut self, activity_repo: Arc<dyn ActivityRepository>) -> Self {
self.activity_repo = Some(activity_repo);
self
}
pub fn follow_repo(mut self, follow_repo: Arc<dyn FollowRepository>) -> Self {
self.follow_repo = Some(follow_repo);
self
}
pub fn actor_repo(mut self, actor_repo: Arc<dyn ActorRepository>) -> Self {
self.actor_repo = Some(actor_repo);
self
}
pub fn blocklist_repo(mut self, blocklist_repo: Arc<dyn BlocklistRepository>) -> Self {
self.blocklist_repo = Some(blocklist_repo);
self
}
pub fn user_repo(mut self, user_repo: Arc<dyn ApUserRepository>) -> Self {
self.user_repo = Some(user_repo);
self
}
pub fn content_reader(mut self, content_reader: Arc<dyn ApContentReader>) -> Self {
self.content_reader = Some(content_reader);
self
}
pub fn object_handler(mut self, object_handler: Arc<dyn ApObjectHandler>) -> Self {
self.object_handler = Some(object_handler);
self
}
pub fn allow_registration(mut self, allow_registration: bool) -> Self {
self.allow_registration = allow_registration;
self
}
pub fn software_name(mut self, software_name: impl Into<String>) -> Self {
self.software_name = software_name.into();
self
}
pub fn debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
pub fn event_publisher(
mut self,
event_publisher: Arc<dyn crate::data::EventPublisher>,
) -> Self {
self.event_publisher = Some(event_publisher);
self
}
pub fn delivery_max_attempts(mut self, delivery_max_attempts: u32) -> Self {
self.delivery_max_attempts = delivery_max_attempts;
self
}
pub fn delivery_initial_delay_secs(mut self, delivery_initial_delay_secs: u64) -> Self {
self.delivery_initial_delay_secs = delivery_initial_delay_secs;
self
}
pub fn actor_cache_ttl_secs(mut self, actor_cache_ttl_secs: u64) -> Self {
self.actor_cache_ttl_secs = actor_cache_ttl_secs;
self
}
pub fn nodeinfo_services(mut self, inbound: Vec<String>, outbound: Vec<String>) -> Self {
self.nodeinfo_services_inbound = inbound;
self.nodeinfo_services_outbound = outbound;
self
}
pub fn nodeinfo_metadata(mut self, metadata: serde_json::Value) -> Self {
self.nodeinfo_metadata = metadata;
self
}
/// Override the default `/users/{uuid}` URL scheme. Consumers with custom
/// actor paths should implement [`UrlScheme`] and pass it here.
pub fn url_scheme(mut self, url_scheme: Arc<dyn UrlScheme>) -> Self {
self.url_scheme = Some(url_scheme);
self
}
/// Set a local actor whose keypair signs all outgoing fetch requests
/// (HTTP Signature on GETs). Required for federating with instances
/// that enforce authorized-fetch / Secure Mode.
pub fn signed_fetch_actor_id(mut self, signed_fetch_actor_id: uuid::Uuid) -> Self {
self.signed_fetch_actor_id = Some(signed_fetch_actor_id);
self
}
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
let activity_repo = self
.activity_repo
.ok_or_else(|| anyhow::anyhow!("activity_repo required — call .activity_repo(arc)"))?;
let follow_repo = self
.follow_repo
.ok_or_else(|| anyhow::anyhow!("follow_repo required — call .follow_repo(arc)"))?;
let actor_repo = self
.actor_repo
.ok_or_else(|| anyhow::anyhow!("actor_repo required — call .actor_repo(arc)"))?;
let blocklist_repo = self.blocklist_repo.ok_or_else(|| {
anyhow::anyhow!("blocklist_repo required — call .blocklist_repo(arc)")
})?;
let user_repo = self
.user_repo
.ok_or_else(|| anyhow::anyhow!("user_repo required — call .user_repo(arc)"))?;
let content_reader = self.content_reader.ok_or_else(|| {
anyhow::anyhow!("content_reader required — call .content_reader(arc)")
})?;
let object_handler = self.object_handler.ok_or_else(|| {
anyhow::anyhow!("object_handler required — call .object_handler(arc)")
})?;
let url_scheme = self
.url_scheme
.unwrap_or_else(|| Arc::new(DefaultUrlScheme));
let data = FederationData::new(
activity_repo,
follow_repo,
actor_repo.clone(),
blocklist_repo,
user_repo.clone(),
content_reader,
object_handler,
self.base_url.clone(),
self.allow_registration,
self.software_name,
self.event_publisher,
std::time::Duration::from_secs(self.actor_cache_ttl_secs),
url_scheme,
)
.with_nodeinfo_services(
self.nodeinfo_services_inbound,
self.nodeinfo_services_outbound,
)
.with_nodeinfo_metadata(self.nodeinfo_metadata);
let signing_actor = if let Some(uid) = self.signed_fetch_actor_id {
let actor = crate::actors::build_local_actor(
uid,
&self.base_url,
user_repo.as_ref(),
actor_repo.as_ref(),
data.url_scheme.as_ref(),
)
.await?;
Some(actor)
} else {
None
};
let federation_config =
ApFederationConfig::new(data, self.debug, signing_actor.as_ref()).await?;
Ok(ActivityPubService {
federation_config,
base_url: self.base_url,
delivery_max_attempts: self.delivery_max_attempts,
delivery_initial_delay_secs: self.delivery_initial_delay_secs,
})
}
}

107
src/service/collections.rs Normal file
View File

@@ -0,0 +1,107 @@
use activitypub_federation::{protocol::context::WithContext, traits::Object};
use crate::actors::get_local_actor;
use super::ActivityPubService;
impl ActivityPubService {
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
let uuid = uuid::Uuid::parse_str(user_id_str)?;
let data = self.federation_config.to_request_data();
let actor = get_local_actor(uuid, &data).await?;
let person = actor.into_json(&data).await?;
Ok(serde_json::to_string(&WithContext::new(
person,
crate::urls::actor_ap_context(),
))?)
}
pub async fn followers_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
let data = self.federation_config.to_request_data();
let actor_url = data.url_scheme.actor_url(&self.base_url, user_id)?;
let collection_url = data.url_scheme.followers_url(&actor_url)?.to_string();
let total = data.follow_repo.count_followers(user_id).await?;
let items_fn = |offset: u32, limit: usize| {
let data = data.clone();
async move {
Ok(data
.follow_repo
.get_followers_page(user_id, offset, limit)
.await?
.into_iter()
.map(|follower| follower.actor.url)
.collect())
}
};
serialize_ordered_collection(&collection_url, total, page, items_fn).await
}
pub async fn following_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
let data = self.federation_config.to_request_data();
let actor_url = data.url_scheme.actor_url(&self.base_url, user_id)?;
let collection_url = data.url_scheme.following_url(&actor_url)?.to_string();
let total = data.follow_repo.count_following(user_id).await?;
let items_fn = |offset: u32, limit: usize| {
let data = data.clone();
async move {
Ok(data
.follow_repo
.get_following_page(user_id, offset, limit)
.await?
.into_iter()
.map(|actor| actor.url)
.collect())
}
};
serialize_ordered_collection(&collection_url, total, page, items_fn).await
}
}
pub(crate) async fn serialize_ordered_collection<F, Fut>(
collection_url: &str,
total: usize,
page: Option<u32>,
fetch_items: F,
) -> anyhow::Result<String>
where
F: FnOnce(u32, usize) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<Vec<String>>>,
{
use crate::urls::{AP_CONTEXT, AP_PAGE_SIZE};
let json = if let Some(page_number) = page {
let page_number = page_number.max(1);
let offset = (page_number.saturating_sub(1) as usize) * AP_PAGE_SIZE;
let items = fetch_items(offset as u32, AP_PAGE_SIZE).await?;
let has_next = offset + items.len() < total;
let mut obj = serde_json::json!({
"@context": AP_CONTEXT,
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_url, page_number),
"partOf": collection_url,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_url, page_number + 1));
}
obj
} else {
serde_json::json!({
"@context": AP_CONTEXT,
"type": "OrderedCollection",
"id": collection_url,
"totalItems": total,
"first": format!("{}?page=1", collection_url),
})
};
Ok(serde_json::to_string(&json)?)
}

View File

@@ -84,28 +84,32 @@ impl Activity for RawActivity {
} }
impl ActivityPubService { impl ActivityPubService {
/// Route deliveries to the EventPublisher (one DeliveryRequested event per inbox) /// Dispatch pre-built `SendActivityTask`s via the event publisher (if configured)
/// or fall back to a fire-and-forget tokio::spawn. /// or by spawning a background retry loop.
/// `pub(crate)` so sibling modules (broadcast.rs, follow.rs) can call it on `self`. fn dispatch_sends(
pub(crate) async fn dispatch_deliveries(
&self, &self,
data: &activitypub_federation::config::Data<FederationData>, data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor, local_actor: &DbActor,
inboxes: Vec<Url>, inboxes: Vec<Url>,
sends: Vec<SendActivityTask>, sends: Vec<SendActivityTask>,
activity_json: serde_json::Value, activity_json: serde_json::Value,
) -> anyhow::Result<()> { ) {
if let Some(publisher) = data.event_publisher.as_ref() { if let Some(publisher) = data.event_publisher.as_ref() {
for inbox in inboxes { let publisher = publisher.clone();
let event = FederationEvent::DeliveryRequested { let signing_actor_id = local_actor.user_id;
inbox, let activity = activity_json;
activity: activity_json.clone(), tokio::spawn(async move {
signing_actor_id: local_actor.user_id, for inbox in inboxes {
}; let event = FederationEvent::DeliveryRequested {
if let Err(e) = publisher.publish(event).await { inbox,
tracing::warn!(error = %e, "failed to enqueue DeliveryRequested event"); activity: activity.clone(),
signing_actor_id,
};
if let Err(error) = publisher.publish(event).await {
tracing::warn!(%error, "failed to enqueue DeliveryRequested event");
}
} }
} });
} else { } else {
let data = data.clone(); let data = data.clone();
let max_attempts = self.delivery_max_attempts; let max_attempts = self.delivery_max_attempts;
@@ -117,7 +121,6 @@ impl ActivityPubService {
} }
}); });
} }
Ok(())
} }
/// Deliver a single outbound activity to `inbox`. /// Deliver a single outbound activity to `inbox`.
@@ -129,9 +132,8 @@ impl ActivityPubService {
signing_actor_id: uuid::Uuid, signing_actor_id: uuid::Uuid,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let actor = get_local_actor(signing_actor_id, &data) let actor = get_local_actor(signing_actor_id, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let id = activity let id = activity
.get("id") .get("id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -147,6 +149,7 @@ impl ActivityPubService {
actor_url, actor_url,
value: activity.clone(), value: activity.clone(),
}; };
let sends = SendActivityTask::prepare(&raw, &actor, vec![inbox.clone()], &data).await?; let sends = SendActivityTask::prepare(&raw, &actor, vec![inbox.clone()], &data).await?;
let failures = send_with_retry( let failures = send_with_retry(
sends, sends,
@@ -158,34 +161,35 @@ impl ActivityPubService {
if failures.is_empty() { if failures.is_empty() {
return Ok(()); return Ok(());
} }
let error_msg = failures let error_msg = failures
.iter() .iter()
.map(|e| e.to_string()) .map(|e| e.to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("; "); .join("; ");
if let Some(publisher) = data.event_publisher.as_ref() {
let _ = publisher if let Some(publisher) = data.event_publisher.as_ref()
&& let Err(error) = publisher
.publish(FederationEvent::DeliveryFailed { .publish(FederationEvent::DeliveryFailed {
inbox, inbox,
activity, activity,
signing_actor_id, signing_actor_id,
error: error_msg.clone(), error: error_msg.clone(),
}) })
.await; .await
{
tracing::warn!(%error, "failed to publish DeliveryFailed event");
} }
Err(anyhow::anyhow!("delivery failed: {}", error_msg)) Err(anyhow::anyhow!("delivery failed: {}", error_msg))
} }
/// Serialize `activity` to JSON and prepare `SendActivityTask` objects. pub(super) async fn send_activity<A>(
/// Returns `(activity_json, sends, inboxes)` so both dispatch paths have what they need.
/// `pub(super)` — visible to all child modules of `service` (broadcast.rs, follow.rs, etc.).
pub(super) async fn prepare_broadcast<A>(
&self, &self,
data: &activitypub_federation::config::Data<FederationData>, data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor, local_actor: &DbActor,
inboxes: Vec<Url>, inboxes: Vec<Url>,
activity: A, activity: A,
) -> anyhow::Result<(serde_json::Value, Vec<SendActivityTask>, Vec<Url>)> ) -> anyhow::Result<()>
where where
A: Activity + Serialize + Debug + Send + Sync, A: Activity + Serialize + Debug + Send + Sync,
{ {
@@ -194,6 +198,35 @@ impl ActivityPubService {
let activity_json = serde_json::to_value(&with_ctx)?; let activity_json = serde_json::to_value(&with_ctx)?;
let sends = let sends =
SendActivityTask::prepare(&with_ctx, local_actor, inboxes.clone(), data).await?; SendActivityTask::prepare(&with_ctx, local_actor, inboxes.clone(), data).await?;
Ok((activity_json, sends, inboxes)) self.dispatch_sends(data, local_actor, inboxes, sends, activity_json);
Ok(())
}
pub(super) async fn send_raw_activity(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor,
inboxes: Vec<Url>,
activity: serde_json::Value,
) -> anyhow::Result<()> {
let id = activity
.get("id")
.and_then(|value| value.as_str())
.and_then(|id_str| Url::parse(id_str).ok())
.unwrap_or_else(|| local_actor.ap_id.clone());
let actor_url = activity
.get("actor")
.and_then(|value| value.as_str())
.and_then(|actor_str| Url::parse(actor_str).ok())
.unwrap_or_else(|| local_actor.ap_id.clone());
let raw = RawActivity {
id,
actor_url,
value: activity.clone(),
};
let sends = SendActivityTask::prepare(&raw, local_actor, inboxes.clone(), data).await?;
self.dispatch_sends(data, local_actor, inboxes, sends, activity);
Ok(())
} }
} }

View File

@@ -17,8 +17,7 @@ impl ActivityPubService {
crate::data::FederationData, crate::data::FederationData,
serde_json::Value, serde_json::Value,
>(url, &data) >(url, &data)
.await .await?;
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(res.object) Ok(res.object)
} }

View File

@@ -6,7 +6,6 @@ use crate::{
actors::get_local_actor, actors::get_local_actor,
data::FederationData, data::FederationData,
repository::{FollowerStatus, FollowingStatus, RemoteActor}, repository::{FollowerStatus, FollowingStatus, RemoteActor},
urls::activity_url,
}; };
use super::ActivityPubService; use super::ActivityPubService;
@@ -19,51 +18,26 @@ impl ActivityPubService {
if parts.len() == 2 && parts[1] == data.domain { if parts.len() == 2 && parts[1] == data.domain {
return self.follow_local(local_user_id, parts[0], &data).await; return self.follow_local(local_user_id, parts[0], &data).await;
} }
let remote_actor = self.webfinger_https(handle, &data).await?; let remote_actor = self.webfinger_https(handle, &data).await?;
let local_actor = get_local_actor(local_user_id, &data) let local_actor = get_local_actor(local_user_id, &data).await?;
.await let follow_id = data.url_scheme.activity_url(&self.base_url)?;
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id_str = follow_id.to_string(); let follow_id_str = follow_id.to_string();
let remote = RemoteActor { let remote = RemoteActor::from(&remote_actor);
url: remote_actor.ap_id.to_string(),
handle: format!(
"{}@{}",
remote_actor.username,
remote_actor.ap_id.host_str().unwrap_or("")
),
inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: remote_actor
.shared_inbox_url
.as_ref()
.map(|u| u.to_string()),
display_name: remote_actor
.display_name
.clone()
.or_else(|| Some(remote_actor.username.clone())),
avatar_url: remote_actor.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: Some(remote_actor.outbox_url.to_string()),
bio: remote_actor.bio.clone(),
banner_url: remote_actor.banner_url.as_ref().map(|u| u.to_string()),
followers_url: Some(remote_actor.followers_url.to_string()),
following_url: Some(remote_actor.following_url.to_string()),
also_known_as: remote_actor.also_known_as.clone(),
fetched_at: Some(chrono::Utc::now()),
};
// Save BEFORE delivering — prevents lost state on process restart. // Save BEFORE delivering — prevents lost state on process restart.
data.follow_repo data.follow_repo
.add_following(local_user_id, remote, &follow_id_str) .add_following(local_user_id, remote, &follow_id_str)
.await?; .await?;
let follow = FollowActivity { let follow = FollowActivity {
id: Url::parse(&follow_id_str)?, id: Url::parse(&follow_id_str)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: ObjectId::from(remote_actor.ap_id.clone()), object: ObjectId::from(remote_actor.ap_id.clone()),
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![remote_actor.inbox()], follow) self.send_activity(&data, &local_actor, vec![remote_actor.inbox()], follow)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await .await
} }
@@ -78,14 +52,13 @@ impl ActivityPubService {
.unfollow_local(local_user_id, actor_url_str, &data) .unfollow_local(local_user_id, actor_url_str, &data)
.await; .await;
} }
let remote = data let remote = data
.actor_repo .actor_repo
.get_remote_actor(actor_url_str) .get_remote_actor(actor_url_str)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?; .ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?;
let local_actor = get_local_actor(local_user_id, &data) let local_actor = get_local_actor(local_user_id, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_ap_id = Url::parse(actor_url_str)?; let remote_ap_id = Url::parse(actor_url_str)?;
let inbox = Url::parse(&remote.inbox_url)?; let inbox = Url::parse(&remote.inbox_url)?;
let follow_id = data let follow_id = data
@@ -94,25 +67,27 @@ impl ActivityPubService {
.await? .await?
.and_then(|id| Url::parse(&id).ok()) .and_then(|id| Url::parse(&id).ok())
.unwrap_or_else(|| { .unwrap_or_else(|| {
activity_url(&self.base_url).unwrap_or_else(|_| remote_ap_id.clone()) data.url_scheme
.activity_url(&self.base_url)
.unwrap_or_else(|_| remote_ap_id.clone())
}); });
let follow = FollowActivity { let follow = FollowActivity {
id: follow_id, id: follow_id,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: ObjectId::from(remote_ap_id), object: ObjectId::from(remote_ap_id),
}; };
let undo = UndoActivity { let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?, object: serde_json::to_value(&follow)?,
}; };
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], undo) self.send_activity(&data, &local_actor, vec![inbox], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?; .await?;
data.follow_repo data.follow_repo
.remove_following(local_user_id, actor_url_str) .remove_following(local_user_id, actor_url_str)
.await?; .await?;
@@ -128,9 +103,7 @@ impl ActivityPubService {
remote_actor_url: &str, remote_actor_url: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data) let local_actor = get_local_actor(local_user_id, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_actor = data let remote_actor = data
.actor_repo .actor_repo
.get_remote_actor(remote_actor_url) .get_remote_actor(remote_actor_url)
@@ -143,27 +116,27 @@ impl ActivityPubService {
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url) anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
})?; })?;
let follow = FollowActivity { let follow = FollowActivity {
id: Url::parse(&follow_id_str)?, id: Url::parse(&follow_id_str)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?), actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()), object: local_actor.object_id(),
}; };
let accept = AcceptActivity { let accept = AcceptActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: follow, object: follow,
}; };
data.follow_repo data.follow_repo
.update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted) .update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted)
.await?; .await?;
let inbox = Url::parse(&remote_actor.inbox_url)?; let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self self.send_activity(&data, &local_actor, vec![inbox], accept)
.prepare_broadcast(&data, &local_actor, vec![inbox], accept)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?; .await?;
let target_inbox = remote_actor let target_inbox = remote_actor
.shared_inbox_url .shared_inbox_url
.clone() .clone()
@@ -178,32 +151,30 @@ impl ActivityPubService {
remote_actor_url: &str, remote_actor_url: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data) let local_actor = get_local_actor(local_user_id, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_actor = data let remote_actor = data
.actor_repo .actor_repo
.get_remote_actor(remote_actor_url) .get_remote_actor(remote_actor_url)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?; .ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
let follow = FollowActivity { let follow = FollowActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?), actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()), object: local_actor.object_id(),
}; };
let reject = RejectActivity { let reject = RejectActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: follow, object: follow,
}; };
let inbox = Url::parse(&remote_actor.inbox_url)?; let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self self.send_activity(&data, &local_actor, vec![inbox], reject)
.prepare_broadcast(&data, &local_actor, vec![inbox], reject)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?; .await?;
data.follow_repo data.follow_repo
.remove_follower(local_user_id, remote_actor_url) .remove_follower(local_user_id, remote_actor_url)
.await?; .await?;
@@ -243,8 +214,8 @@ impl ActivityPubService {
.get_followers(local_user_id) .get_followers(local_user_id)
.await? .await?
.into_iter() .into_iter()
.filter(|f| f.status == FollowerStatus::Accepted) .filter(|follower| follower.status == FollowerStatus::Accepted)
.map(|f| f.actor) .map(|follower| follower.actor)
.collect()) .collect())
} }
@@ -292,29 +263,32 @@ impl ActivityPubService {
data.blocklist_repo data.blocklist_repo
.add_blocked_actor(local_user_id, actor_url) .add_blocked_actor(local_user_id, actor_url)
.await?; .await?;
let _ = data if let Err(error) = data
.follow_repo .follow_repo
.remove_follower(local_user_id, actor_url) .remove_follower(local_user_id, actor_url)
.await; .await
let _ = data {
tracing::debug!(%error, "follower already removed");
}
if let Err(error) = data
.follow_repo .follow_repo
.remove_following(local_user_id, actor_url) .remove_following(local_user_id, actor_url)
.await;
let local_actor = get_local_actor(local_user_id, &data)
.await .await
.map_err(|e| anyhow::anyhow!("{e}"))?; {
tracing::debug!(%error, "following already removed");
}
let local_actor = get_local_actor(local_user_id, &data).await?;
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await { if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
let block = crate::activities::BlockActivity { let block = crate::activities::BlockActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: Url::parse(actor_url)?, object: Url::parse(actor_url)?,
}; };
let inbox = Url::parse(&remote_actor.inbox_url)?; let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self self.send_activity(&data, &local_actor, vec![inbox], block)
.prepare_broadcast(&data, &local_actor, vec![inbox], block)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?; .await?;
} }
Ok(()) Ok(())
@@ -329,27 +303,24 @@ impl ActivityPubService {
data.blocklist_repo data.blocklist_repo
.remove_blocked_actor(local_user_id, actor_url) .remove_blocked_actor(local_user_id, actor_url)
.await?; .await?;
let local_actor = get_local_actor(local_user_id, &data)
.await let local_actor = get_local_actor(local_user_id, &data).await?;
.map_err(|e| anyhow::anyhow!("{e}"))?;
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await { if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
let block = crate::activities::BlockActivity { let block = crate::activities::BlockActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: Url::parse(actor_url)?, object: Url::parse(actor_url)?,
}; };
let undo = UndoActivity { let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(), kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()), actor: local_actor.object_id(),
object: serde_json::to_value(&block).map_err(|e| anyhow::anyhow!("{e}"))?, object: serde_json::to_value(&block)?,
}; };
let inbox = Url::parse(&remote_actor.inbox_url)?; let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self self.send_activity(&data, &local_actor, vec![inbox], undo)
.prepare_broadcast(&data, &local_actor, vec![inbox], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?; .await?;
tracing::info!(actor = %actor_url, "sent Undo(Block)"); tracing::info!(actor = %actor_url, "sent Undo(Block)");
} }
@@ -368,22 +339,8 @@ impl ActivityPubService {
let mut actors = Vec::new(); let mut actors = Vec::new();
for url in actor_urls { for url in actor_urls {
let actor = match data.actor_repo.get_remote_actor(&url).await { let actor = match data.actor_repo.get_remote_actor(&url).await {
Ok(Some(a)) => a, Ok(Some(cached)) => cached,
_ => RemoteActor { _ => RemoteActor::placeholder(url),
url: url.clone(),
handle: url.clone(),
inbox_url: url.clone(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
outbox_url: None,
bio: None,
banner_url: None,
followers_url: None,
following_url: None,
also_known_as: vec![],
fetched_at: None,
},
}; };
actors.push(actor); actors.push(actor);
} }
@@ -404,11 +361,14 @@ impl ActivityPubService {
if target.id == local_user_id { if target.id == local_user_id {
return Err(anyhow::anyhow!("cannot follow yourself")); return Err(anyhow::anyhow!("cannot follow yourself"));
} }
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id); let follower_actor_url = data
let follow_id = activity_url(&self.base_url) .url_scheme
.map_err(|e| anyhow::anyhow!("{e}"))? .actor_url(&self.base_url, local_user_id)?
.to_string(); .to_string();
let target_actor_url = data.url_scheme.actor_url(&self.base_url, target.id)?;
let follow_id = data.url_scheme.activity_url(&self.base_url)?.to_string();
data.follow_repo data.follow_repo
.add_follower( .add_follower(
target.id, target.id,
@@ -417,21 +377,31 @@ impl ActivityPubService {
&follow_id, &follow_id,
) )
.await?; .await?;
let target_as_remote = RemoteActor { let target_as_remote = RemoteActor {
url: target_actor_url.to_string(), url: target_actor_url.to_string(),
handle: format!("{}@{}", target.username, data.domain), handle: format!("{}@{}", target.username, data.domain),
inbox_url: format!("{}/inbox", target_actor_url), inbox_url: data.url_scheme.inbox_url(&target_actor_url)?.to_string(),
shared_inbox_url: None, shared_inbox_url: None,
display_name: target.display_name.or(Some(target.username)), display_name: target.display_name.or(Some(target.username)),
avatar_url: target.avatar_url.as_ref().map(|u| u.to_string()), avatar_url: target.avatar_url.as_ref().map(|url| url.to_string()),
outbox_url: Some(format!("{}/outbox", target_actor_url)), outbox_url: Some(data.url_scheme.outbox_url(&target_actor_url)?.to_string()),
bio: target.bio, bio: target.bio,
banner_url: target.banner_url.as_ref().map(|u| u.to_string()), banner_url: target.banner_url.as_ref().map(|url| url.to_string()),
followers_url: Some(format!("{}/followers", target_actor_url)), followers_url: Some(
following_url: Some(format!("{}/following", target_actor_url)), data.url_scheme
.followers_url(&target_actor_url)?
.to_string(),
),
following_url: Some(
data.url_scheme
.following_url(&target_actor_url)?
.to_string(),
),
also_known_as: target.also_known_as, also_known_as: target.also_known_as,
fetched_at: None, fetched_at: None,
}; };
data.follow_repo data.follow_repo
.add_following(local_user_id, target_as_remote, &follow_id) .add_following(local_user_id, target_as_remote, &follow_id)
.await?; .await?;
@@ -442,6 +412,7 @@ impl ActivityPubService {
FollowingStatus::Accepted, FollowingStatus::Accepted,
) )
.await?; .await?;
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow"); tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
Ok(()) Ok(())
} }
@@ -453,15 +424,22 @@ impl ActivityPubService {
data: &activitypub_federation::config::Data<FederationData>, data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let target_url = Url::parse(target_actor_url)?; let target_url = Url::parse(target_actor_url)?;
let target_user_id = crate::urls::extract_user_id_from_url(&target_url) let target_user_id = data
.url_scheme
.extract_user_id(&target_url)
.ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?; .ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?;
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string(); let local_actor_url = data
.url_scheme
.actor_url(&self.base_url, local_user_id)?
.to_string();
data.follow_repo data.follow_repo
.remove_follower(target_user_id, &local_actor_url) .remove_follower(target_user_id, &local_actor_url)
.await?; .await?;
data.follow_repo data.follow_repo
.remove_following(local_user_id, target_actor_url) .remove_following(local_user_id, target_actor_url)
.await?; .await?;
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow"); tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
Ok(()) Ok(())
} }

152
src/service/lookup.rs Normal file
View File

@@ -0,0 +1,152 @@
use url::Url;
use crate::{actors::DbActor, data::FederationData, repository::BlockedDomain};
use super::ActivityPubService;
struct ParsedHandle<'a> {
username: &'a str,
domain: &'a str,
}
fn parse_handle(handle: &str) -> anyhow::Result<ParsedHandle<'_>> {
let normalized = handle.trim_start_matches('@');
let separator_index = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
Ok(ParsedHandle {
username: &normalized[..separator_index],
domain: &normalized[separator_index + 1..],
})
}
fn webfinger_url(handle: &ParsedHandle<'_>) -> String {
format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
handle.domain, handle.username, handle.domain
)
}
async fn fetch_webfinger(url: &str) -> anyhow::Result<serde_json::Value> {
let parsed = Url::parse(url)?;
crate::security::validate_url(&parsed).await?;
Ok(reqwest::Client::new()
.get(url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?)
}
fn extract_actor_href(webfinger: &serde_json::Value) -> anyhow::Result<String> {
webfinger["links"]
.as_array()
.and_then(|links| {
links.iter().find(|link| {
link["rel"].as_str() == Some("self")
&& link["type"].as_str() == Some(crate::urls::AP_CONTENT_TYPE)
})
})
.and_then(|link| link["href"].as_str())
.map(|href| href.to_owned())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))
}
impl ActivityPubService {
// ── Pass-through wrappers ───────────────────────────────────────────
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.follow_repo
.update_follower_status(
user_id,
actor_url,
crate::repository::FollowerStatus::Accepted,
)
.await
}
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.follow_repo.remove_follower(user_id, actor_url).await
}
pub async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.add_blocked_domain(domain, reason).await
}
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.remove_blocked_domain(domain).await
}
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.get_blocked_domains().await
}
// ── WebFinger / actor resolution ────────────────────────────────────
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(|error| tracing::warn!(handle, %error, "actor lookup failed"))?;
let domain = actor.ap_id.host_str().unwrap_or("").to_string();
tracing::info!(handle = format!("{}@{}", actor.username, domain), ap_url = %actor.ap_id, "remote actor resolved");
Ok(crate::user::LookedUpActor {
handle: format!("{}@{}", actor.username, domain),
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,
})
}
pub(super) async fn webfinger_https(
&self,
handle: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<DbActor> {
let parsed_handle = parse_handle(handle)?;
let url = webfinger_url(&parsed_handle);
tracing::debug!(handle, webfinger_url = %url, "resolving webfinger");
let webfinger_response = fetch_webfinger(&url).await?;
let actor_href = extract_actor_href(&webfinger_response)?;
tracing::debug!(handle, actor_href, "webfinger resolved, fetching actor");
let actor: DbActor =
activitypub_federation::fetch::object_id::ObjectId::from(Url::parse(&actor_href)?)
.dereference(data)
.await?;
Ok(actor)
}
}

View File

@@ -1,30 +1,31 @@
use std::sync::Arc;
use activitypub_federation::{protocol::context::WithContext, traits::Object};
use axum::{Router, extract::DefaultBodyLimit, routing::get, routing::post};
use url::Url; use url::Url;
use axum::{Router, extract::DefaultBodyLimit, routing::get, routing::post};
use crate::{ use crate::{
actors::{DbActor, get_local_actor}, actors::{DbActor, get_local_actor},
content::{ApContentReader, ApObjectHandler},
data::FederationData, data::FederationData,
featured_handler::featured_handler,
federation::ApFederationConfig, federation::ApFederationConfig,
inbox::inbox_handler, handlers::{
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, featured::featured_handler,
outbox::outbox_handler, inbox::inbox_handler,
repository::{ nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, outbox::outbox_handler,
webfinger::webfinger_handler,
}, },
user::ApUserRepository,
webfinger::webfinger_handler,
}; };
mod backfill; mod backfill;
pub(crate) mod broadcast; pub(crate) mod broadcast;
mod builder;
pub(crate) mod collections;
pub(super) mod delivery; pub(super) mod delivery;
mod fetch; mod fetch;
mod follow; mod follow;
mod lookup;
pub(crate) mod types;
pub use builder::ActivityPubServiceBuilder;
/// Default max delivery retries per inbox (used as the builder default). /// Default max delivery retries per inbox (used as the builder default).
pub const DELIVERY_MAX_ATTEMPTS: u32 = 3; pub const DELIVERY_MAX_ATTEMPTS: u32 = 3;
@@ -45,195 +46,9 @@ pub struct ActivityPubService {
pub(super) delivery_initial_delay_secs: u64, pub(super) delivery_initial_delay_secs: u64,
} }
pub struct ActivityPubServiceBuilder {
activity_repo: Option<Arc<dyn ActivityRepository>>,
follow_repo: Option<Arc<dyn FollowRepository>>,
actor_repo: Option<Arc<dyn ActorRepository>>,
blocklist_repo: Option<Arc<dyn BlocklistRepository>>,
user_repo: Option<Arc<dyn ApUserRepository>>,
content_reader: Option<Arc<dyn ApContentReader>>,
object_handler: Option<Arc<dyn ApObjectHandler>>,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
delivery_max_attempts: u32,
delivery_initial_delay_secs: u64,
signed_fetch_actor_id: Option<uuid::Uuid>,
actor_cache_ttl_secs: u64,
nodeinfo_services_inbound: Vec<String>,
nodeinfo_services_outbound: Vec<String>,
nodeinfo_metadata: serde_json::Value,
}
impl ActivityPubServiceBuilder {
pub fn activity_repo(mut self, v: Arc<dyn ActivityRepository>) -> Self {
self.activity_repo = Some(v);
self
}
pub fn follow_repo(mut self, v: Arc<dyn FollowRepository>) -> Self {
self.follow_repo = Some(v);
self
}
pub fn actor_repo(mut self, v: Arc<dyn ActorRepository>) -> Self {
self.actor_repo = Some(v);
self
}
pub fn blocklist_repo(mut self, v: Arc<dyn BlocklistRepository>) -> Self {
self.blocklist_repo = Some(v);
self
}
pub fn user_repo(mut self, v: Arc<dyn ApUserRepository>) -> Self {
self.user_repo = Some(v);
self
}
pub fn content_reader(mut self, v: Arc<dyn ApContentReader>) -> Self {
self.content_reader = Some(v);
self
}
pub fn object_handler(mut self, v: Arc<dyn ApObjectHandler>) -> Self {
self.object_handler = Some(v);
self
}
pub fn allow_registration(mut self, v: bool) -> Self {
self.allow_registration = v;
self
}
pub fn software_name(mut self, v: impl Into<String>) -> Self {
self.software_name = v.into();
self
}
pub fn debug(mut self, v: bool) -> Self {
self.debug = v;
self
}
pub fn event_publisher(mut self, v: Arc<dyn crate::data::EventPublisher>) -> Self {
self.event_publisher = Some(v);
self
}
pub fn delivery_max_attempts(mut self, v: u32) -> Self {
self.delivery_max_attempts = v;
self
}
pub fn delivery_initial_delay_secs(mut self, v: u64) -> Self {
self.delivery_initial_delay_secs = v;
self
}
/// How long cached remote actors are considered fresh (seconds, default 24h).
/// After this duration, the next access re-fetches the actor from origin.
pub fn actor_cache_ttl_secs(mut self, v: u64) -> Self {
self.actor_cache_ttl_secs = v;
self
}
pub fn nodeinfo_services(mut self, inbound: Vec<String>, outbound: Vec<String>) -> Self {
self.nodeinfo_services_inbound = inbound;
self.nodeinfo_services_outbound = outbound;
self
}
pub fn nodeinfo_metadata(mut self, metadata: serde_json::Value) -> Self {
self.nodeinfo_metadata = metadata;
self
}
/// Set a local actor whose keypair signs all outgoing fetch requests
/// (HTTP Signature on GETs). Required for federating with instances
/// that enforce authorized-fetch / Secure Mode.
pub fn signed_fetch_actor_id(mut self, v: uuid::Uuid) -> Self {
self.signed_fetch_actor_id = Some(v);
self
}
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
let activity_repo = self
.activity_repo
.ok_or_else(|| anyhow::anyhow!("activity_repo required — call .activity_repo(arc)"))?;
let follow_repo = self
.follow_repo
.ok_or_else(|| anyhow::anyhow!("follow_repo required — call .follow_repo(arc)"))?;
let actor_repo = self
.actor_repo
.ok_or_else(|| anyhow::anyhow!("actor_repo required — call .actor_repo(arc)"))?;
let blocklist_repo = self.blocklist_repo.ok_or_else(|| {
anyhow::anyhow!("blocklist_repo required — call .blocklist_repo(arc)")
})?;
let user_repo = self
.user_repo
.ok_or_else(|| anyhow::anyhow!("user_repo required — call .user_repo(arc)"))?;
let content_reader = self.content_reader.ok_or_else(|| {
anyhow::anyhow!("content_reader required — call .content_reader(arc)")
})?;
let object_handler = self.object_handler.ok_or_else(|| {
anyhow::anyhow!("object_handler required — call .object_handler(arc)")
})?;
let data = FederationData::new(
activity_repo,
follow_repo,
actor_repo.clone(),
blocklist_repo,
user_repo.clone(),
content_reader,
object_handler,
self.base_url.clone(),
self.allow_registration,
self.software_name,
self.event_publisher,
std::time::Duration::from_secs(self.actor_cache_ttl_secs),
)
.with_nodeinfo_services(
self.nodeinfo_services_inbound,
self.nodeinfo_services_outbound,
)
.with_nodeinfo_metadata(self.nodeinfo_metadata);
let signing_actor = if let Some(uid) = self.signed_fetch_actor_id {
let actor = crate::actors::build_local_actor(
uid,
&self.base_url,
user_repo.as_ref(),
actor_repo.as_ref(),
)
.await?;
Some(actor)
} else {
None
};
let federation_config =
ApFederationConfig::new(data, self.debug, signing_actor.as_ref()).await?;
Ok(ActivityPubService {
federation_config,
base_url: self.base_url,
delivery_max_attempts: self.delivery_max_attempts,
delivery_initial_delay_secs: self.delivery_initial_delay_secs,
})
}
}
impl ActivityPubService { impl ActivityPubService {
pub fn builder(base_url: impl Into<String>) -> ActivityPubServiceBuilder { pub fn builder(base_url: impl Into<String>) -> ActivityPubServiceBuilder {
ActivityPubServiceBuilder { ActivityPubServiceBuilder::new(base_url.into())
activity_repo: None,
follow_repo: None,
actor_repo: None,
blocklist_repo: None,
user_repo: None,
content_reader: None,
object_handler: None,
base_url: base_url.into(),
allow_registration: false,
software_name: String::new(),
debug: false,
event_publisher: None,
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
signed_fetch_actor_id: None,
actor_cache_ttl_secs: ACTOR_CACHE_TTL_SECS,
nodeinfo_services_inbound: vec![],
nodeinfo_services_outbound: vec![],
nodeinfo_metadata: serde_json::json!({}),
}
} }
pub fn federation_config(&self) -> &ApFederationConfig { pub fn federation_config(&self) -> &ApFederationConfig {
@@ -269,178 +84,23 @@ impl ActivityPubService {
.route("/.well-known/webfinger", get(webfinger_handler)) .route("/.well-known/webfinger", get(webfinger_handler))
.route( .route(
"/inbox", "/inbox",
post(inbox_handler).layer(DefaultBodyLimit::max(1024 * 1024)), post(inbox_handler).layer(DefaultBodyLimit::max(crate::urls::INBOX_BODY_LIMIT)),
) )
.route( .route(
"/users/{id}/inbox", "/users/{id}/inbox",
post(inbox_handler).layer(DefaultBodyLimit::max(1024 * 1024)), post(inbox_handler).layer(DefaultBodyLimit::max(crate::urls::INBOX_BODY_LIMIT)),
) )
.route("/users/{id}/outbox", get(outbox_handler)) .route("/users/{id}/outbox", get(outbox_handler))
.route("/users/{id}/featured", get(featured_handler)) .route("/users/{id}/featured", get(featured_handler))
.layer(self.federation_config.middleware()) .layer(self.federation_config.middleware())
} }
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
let uuid = uuid::Uuid::parse_str(user_id_str)?;
let data = self.federation_config.to_request_data();
let actor = get_local_actor(uuid, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = actor
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(serde_json::to_string(&WithContext::new(
person,
crate::urls::actor_ap_context(),
))?)
}
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.follow_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
.follow_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)?)
}
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.follow_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
.follow_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 mark_follower_accepted(
&self,
user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo
.update_follower_status(
user_id,
actor_url,
crate::repository::FollowerStatus::Accepted,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
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.follow_repo
.remove_follower(user_id, actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
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();
tracing::info!(handle = format!("{}@{}", actor.username, domain), ap_url = %actor.ap_id, "remote actor resolved");
Ok(crate::user::LookedUpActor {
handle: format!("{}@{}", actor.username, domain),
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,
})
}
pub async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.add_blocked_domain(domain, reason).await
}
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.remove_blocked_domain(domain).await
}
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.get_blocked_domains().await
}
// ── Private helpers (accessible to child modules via Rust's privacy rules) ─
async fn accepted_follower_inboxes( async fn accepted_follower_inboxes(
&self, &self,
data: &activitypub_federation::config::Data<FederationData>, data: &activitypub_federation::config::Data<FederationData>,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
) -> anyhow::Result<Option<(DbActor, Vec<Url>)>> { ) -> anyhow::Result<Option<(DbActor, Vec<Url>)>> {
let local_actor = get_local_actor(local_user_id, data) let local_actor = get_local_actor(local_user_id, data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let inbox_strs = data let inbox_strs = data
.follow_repo .follow_repo
.get_accepted_follower_inboxes(local_user_id) .get_accepted_follower_inboxes(local_user_id)
@@ -448,62 +108,12 @@ impl ActivityPubService {
if inbox_strs.is_empty() { if inbox_strs.is_empty() {
return Ok(None); return Ok(None);
} }
let inboxes: Vec<Url> = inbox_strs.into_iter().filter_map(|s| { let inboxes: Vec<Url> = inbox_strs.into_iter().filter_map(|inbox_str| {
Url::parse(&s).map_err(|e| tracing::warn!(inbox = %s, error = %e, "skipping unparseable inbox URL")).ok() Url::parse(&inbox_str).map_err(|e| tracing::warn!(inbox = %inbox_str, error = %e, "skipping unparseable inbox URL")).ok()
}).collect(); }).collect();
if inboxes.is_empty() { if inboxes.is_empty() {
return Ok(None); return Ok(None);
} }
Ok(Some((local_actor, inboxes))) Ok(Some((local_actor, inboxes)))
} }
async fn webfinger_https(
&self,
handle: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<DbActor> {
let normalized = handle.trim_start_matches('@');
let at = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
let wf_url = format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str
);
tracing::debug!(handle, wf_url, "resolving webfinger");
let wf_parsed = Url::parse(&wf_url)?;
crate::security::validate_url(&wf_parsed).await?;
let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?;
let self_href = wf["links"]
.as_array()
.and_then(|links| {
links.iter().find(|l| {
l["rel"].as_str() == Some("self")
&& l["type"].as_str() == Some("application/activity+json")
})
})
.and_then(|l| l["href"].as_str())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
.to_owned();
tracing::debug!(handle, self_href, "webfinger resolved, fetching actor");
let actor: DbActor =
activitypub_federation::fetch::object_id::ObjectId::from(url::Url::parse(&self_href)?)
.dereference(data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(actor)
}
}
#[cfg(test)]
mod tests {
// Inbox deduplication and broadcast filtering are now tested via repository
// integration tests in the consuming crate. See get_accepted_follower_inboxes.
} }

63
src/service/types.rs Normal file
View File

@@ -0,0 +1,63 @@
use url::Url;
use crate::user::ApVisibility;
pub(crate) struct Addressing {
pub to: Vec<String>,
pub cc: Vec<String>,
}
pub(crate) fn visibility_addressing(visibility: ApVisibility, followers_url: &Url) -> Addressing {
match visibility {
ApVisibility::Public => Addressing {
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
},
ApVisibility::FollowersOnly => Addressing {
to: vec![followers_url.to_string()],
cc: vec![],
},
ApVisibility::Private => Addressing {
to: vec![],
cc: vec![],
},
}
}
#[derive(serde::Serialize)]
pub(super) struct AnnounceRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
pub actor: String,
pub object: String,
}
#[derive(serde::Serialize)]
pub(super) struct LikeRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
pub actor: String,
pub object: String,
}
#[derive(serde::Serialize)]
pub(super) struct TombstoneRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
}
#[derive(serde::Serialize)]
pub(super) struct AddRef {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: String,
pub object: AddRefObject,
}
#[derive(serde::Serialize)]
pub(super) struct AddRefObject {
pub id: String,
}

312
src/testing.rs Normal file
View File

@@ -0,0 +1,312 @@
//! Mock builders for testing all k-ap traits.
//!
//! **Not behind `#[cfg(test)]`** so downstream consumers (e.g. movies-diary)
//! can use these mocks in their own test suites.
//!
//! # Usage
//!
//! ```ignore
//! let follow_repo = MockFollowRepoBuilder::new()
//! .on_add_follower(|id, url, status, _| {
//! // custom assertion / tracking
//! Ok(())
//! })
//! .build();
//! ```
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
use crate::content::{ApContentReader, ApObjectHandler, LocalObject};
use crate::data::EventPublisher;
use crate::data::FederationEvent;
use crate::repository::{
ActivityRepository, ActorBlocklist, AnnounceRepository, BlockedDomain, DomainBlocklist,
FollowMigration, Follower, FollowerReader, FollowerStatus, FollowerWriter, FollowingReader,
FollowingStatus, FollowingWriter, Keypair, KeypairRepository, RemoteActor, RemoteActorCache,
};
use crate::user::{ApUser, ApUserRepository};
/// Generate a mock struct + builder + trait impls from a compact spec.
///
/// Each method stores a `Box<dyn Fn(…) -> anyhow::Result<Ret>>` closure.
/// The builder defaults every unset method to `Ok(Default::default())`.
macro_rules! mock_repo {
(
$mock:ident, $builder:ident {
$(
trait $trait_name:ident {
$(
fn $method:ident( $( $pname:ident : $pty:ty ),* $(,)? ) -> $ret:ty;
)*
}
)*
}
) => {
pub struct $mock {
$($(
$method: Box<dyn Fn($($pty),*) -> anyhow::Result<$ret> + Send + Sync>,
)*)*
}
pub struct $builder {
$($(
$method: Option<Box<dyn Fn($($pty),*) -> anyhow::Result<$ret> + Send + Sync>>,
)*)*
}
impl $builder {
pub fn new() -> Self {
Self {
$($(
$method: None,
)*)*
}
}
paste::paste! {
$($(
pub fn [<on_ $method>](
mut self,
f: impl Fn($($pty),*) -> anyhow::Result<$ret> + Send + Sync + 'static,
) -> Self {
self.$method = Some(Box::new(f));
self
}
)*)*
}
pub fn build(self) -> Arc<$mock> {
Arc::new($mock {
$($(
$method: self.$method.unwrap_or_else(||
Box::new(|$(_: $pty),*| Ok(Default::default()))
),
)*)*
})
}
}
impl Default for $builder {
fn default() -> Self {
Self::new()
}
}
$(
#[async_trait]
impl $trait_name for $mock {
$(
async fn $method(&self, $($pname: $pty),*) -> anyhow::Result<$ret> {
(self.$method)($($pname),*)
}
)*
}
)*
};
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// MockFollowRepo
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mock_repo! {
MockFollowRepo, MockFollowRepoBuilder {
trait FollowerWriter {
fn add_follower(
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str
) -> ();
fn get_follower_follow_activity_id(
local_user_id: uuid::Uuid,
remote_actor_url: &str
) -> Option<String>;
fn remove_follower(
local_user_id: uuid::Uuid,
remote_actor_url: &str
) -> ();
fn update_follower_status(
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus
) -> ();
}
trait FollowerReader {
fn get_followers(local_user_id: uuid::Uuid) -> Vec<Follower>;
fn get_followers_page(
local_user_id: uuid::Uuid,
offset: u32,
limit: usize
) -> Vec<Follower>;
fn count_followers(local_user_id: uuid::Uuid) -> usize;
fn get_pending_followers(local_user_id: uuid::Uuid) -> Vec<RemoteActor>;
fn get_accepted_follower_inboxes(local_user_id: uuid::Uuid) -> Vec<String>;
fn count_accepted_followers(local_user_id: uuid::Uuid) -> usize;
fn get_accepted_followers_page(
local_user_id: uuid::Uuid,
offset: u32,
limit: usize
) -> Vec<RemoteActor>;
}
trait FollowingWriter {
fn add_following(
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str
) -> ();
fn get_follow_activity_id(
local_user_id: uuid::Uuid,
remote_actor_url: &str
) -> Option<String>;
fn remove_following(
local_user_id: uuid::Uuid,
actor_url: &str
) -> ();
fn update_following_status(
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus
) -> ();
}
trait FollowingReader {
fn get_following(local_user_id: uuid::Uuid) -> Vec<RemoteActor>;
fn get_following_page(
local_user_id: uuid::Uuid,
offset: u32,
limit: usize
) -> Vec<RemoteActor>;
fn count_following(local_user_id: uuid::Uuid) -> usize;
}
trait FollowMigration {
fn migrate_follower_actor(
old_actor_url: &str,
new_actor_url: &str
) -> Vec<uuid::Uuid>;
}
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// MockActorRepo
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mock_repo! {
MockActorRepo, MockActorRepoBuilder {
trait KeypairRepository {
fn get_local_actor_keypair(user_id: uuid::Uuid) -> Option<Keypair>;
fn save_local_actor_keypair(user_id: uuid::Uuid, keypair: Keypair) -> ();
}
trait RemoteActorCache {
fn upsert_remote_actor(actor: RemoteActor) -> ();
fn get_remote_actor(actor_url: &str) -> Option<RemoteActor>;
}
trait AnnounceRepository {
fn add_announce(
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: DateTime<Utc>
) -> ();
fn remove_announce(activity_id: &str, actor_url: &str) -> ();
fn count_announces(object_url: &str) -> usize;
}
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// MockBlocklistRepo
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mock_repo! {
MockBlocklistRepo, MockBlocklistRepoBuilder {
trait DomainBlocklist {
fn add_blocked_domain(domain: &str, reason: Option<&str>) -> ();
fn remove_blocked_domain(domain: &str) -> ();
fn get_blocked_domains() -> Vec<BlockedDomain>;
fn is_domain_blocked(domain: &str) -> bool;
}
trait ActorBlocklist {
fn add_blocked_actor(local_user_id: uuid::Uuid, actor_url: &str) -> ();
fn remove_blocked_actor(local_user_id: uuid::Uuid, actor_url: &str) -> ();
fn get_blocked_actors(local_user_id: uuid::Uuid) -> Vec<String>;
fn is_actor_blocked(local_user_id: uuid::Uuid, actor_url: &str) -> bool;
}
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// MockActivityRepo
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mock_repo! {
MockActivityRepo, MockActivityRepoBuilder {
trait ActivityRepository {
fn is_activity_processed(activity_id: &str) -> bool;
fn mark_activity_processed(activity_id: &str) -> ();
}
}
}
mock_repo! {
MockUserRepo, MockUserRepoBuilder {
trait ApUserRepository {
fn find_by_id(id: uuid::Uuid) -> Option<ApUser>;
fn find_by_username(username: &str) -> Option<ApUser>;
fn count_users() -> usize;
}
}
}
mock_repo! {
MockContentReader, MockContentReaderBuilder {
trait ApContentReader {
fn get_local_objects_page(
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize
) -> Vec<LocalObject>;
fn count_local_posts() -> u64;
fn get_featured_objects(user_id: uuid::Uuid) -> Vec<Url>;
}
}
}
mock_repo! {
MockObjectHandler, MockObjectHandlerBuilder {
trait ApObjectHandler {
fn on_create(ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> ();
fn on_update(ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> ();
fn on_delete(ap_id: &Url, actor_url: &Url) -> ();
fn on_actor_removed(actor_url: &Url) -> ();
fn on_like(object_url: &Url, actor_url: &Url) -> ();
fn on_unlike(object_url: &Url, actor_url: &Url) -> ();
fn on_announce_received(object_url: &Url, actor_url: &Url) -> ();
fn on_announce_removed(object_url: &Url, actor_url: &Url) -> ();
fn on_announce_of_remote(object_url: &Url, actor_url: &Url) -> ();
fn on_mention(
thought_ap_id: &Url,
mentioned_user_uuid: uuid::Uuid,
actor_url: &Url
) -> ();
fn on_unknown_activity(
activity_type: &str,
activity: serde_json::Value,
actor_url: &Url
) -> ();
}
}
}
mock_repo! {
MockEventPublisher, MockEventPublisherBuilder {
trait EventPublisher {
fn publish(event: FederationEvent) -> ();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,47 +11,47 @@ fn followers_url() -> Url {
#[test] #[test]
fn public_visibility_addresses_public_and_followers() { fn public_visibility_addresses_public_and_followers() {
let (to, cc) = visibility_addressing(ApVisibility::Public, &followers_url()); let addressing = visibility_addressing(ApVisibility::Public, &followers_url());
assert_eq!(to, vec![AS_PUBLIC.to_string()]); assert_eq!(addressing.to, vec![AS_PUBLIC.to_string()]);
assert_eq!(cc, vec![followers_url().to_string()]); assert_eq!(addressing.cc, vec![followers_url().to_string()]);
} }
#[test] #[test]
fn followers_only_visibility_addresses_followers_only() { fn followers_only_visibility_addresses_followers_only() {
let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); let addressing = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert_eq!(to, vec![followers_url().to_string()]); assert_eq!(addressing.to, vec![followers_url().to_string()]);
assert!( assert!(
cc.is_empty(), addressing.cc.is_empty(),
"FollowersOnly must not include AS_PUBLIC in cc" "FollowersOnly must not include AS_PUBLIC in cc"
); );
} }
#[test] #[test]
fn followers_only_excludes_as_public() { fn followers_only_excludes_as_public() {
let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); let addressing = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert!( assert!(
!to.contains(&AS_PUBLIC.to_string()), !addressing.to.contains(&AS_PUBLIC.to_string()),
"FollowersOnly must not include AS_PUBLIC in to" "FollowersOnly must not include AS_PUBLIC in to"
); );
assert!( assert!(
!cc.contains(&AS_PUBLIC.to_string()), !addressing.cc.contains(&AS_PUBLIC.to_string()),
"FollowersOnly must not include AS_PUBLIC in cc" "FollowersOnly must not include AS_PUBLIC in cc"
); );
} }
#[test] #[test]
fn private_visibility_produces_empty_addressing() { fn private_visibility_produces_empty_addressing() {
let (to, cc) = visibility_addressing(ApVisibility::Private, &followers_url()); let addressing = visibility_addressing(ApVisibility::Private, &followers_url());
assert!(to.is_empty()); assert!(addressing.to.is_empty());
assert!(cc.is_empty()); assert!(addressing.cc.is_empty());
} }
#[test] #[test]
fn public_and_followers_only_differ_in_to() { fn public_and_followers_only_differ_in_to() {
let (pub_to, _) = visibility_addressing(ApVisibility::Public, &followers_url()); let public = visibility_addressing(ApVisibility::Public, &followers_url());
let (fo_to, _) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); let followers_only = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert_ne!( assert_ne!(
pub_to, fo_to, public.to, followers_only.to,
"Public and FollowersOnly must produce different to fields" "Public and FollowersOnly must produce different to fields"
); );
} }

View File

@@ -1,399 +1,107 @@
// src/tests/integration.rs // src/tests/integration.rs
/// Integration tests with in-memory trait stubs. /// Integration tests with mock builders.
use std::collections::{HashMap, HashSet}; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use url::Url; use url::Url;
use crate::content::{ApContentReader, ApObjectHandler};
use crate::data::FederationData; use crate::data::FederationData;
use crate::repository::{ use crate::testing::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, MockActivityRepoBuilder, MockActorRepoBuilder, MockBlocklistRepoBuilder,
Follower, FollowerStatus, FollowingStatus, RemoteActor, MockContentReaderBuilder, MockFollowRepoBuilder, MockObjectHandlerBuilder, MockUserRepoBuilder,
}; };
use crate::user::{ApActorType, ApUser, ApUserRepository}; use crate::user::{ApActorType, ApUser};
// ── ActivityRepository ──────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
#[derive(Default)] fn make_user(id: uuid::Uuid, username: &str) -> ApUser {
struct MemActivityRepo { ApUser {
processed: Mutex<HashSet<String>>, id,
} username: username.to_string(),
display_name: None,
#[async_trait] bio: None,
impl ActivityRepository for MemActivityRepo { avatar_url: None,
async fn is_activity_processed(&self, id: &str) -> anyhow::Result<bool> { banner_url: None,
Ok(self.processed.lock().await.contains(id)) also_known_as: vec![],
} profile_url: None,
async fn mark_activity_processed(&self, id: &str) -> anyhow::Result<()> { attachment: vec![],
self.processed.lock().await.insert(id.to_string()); manually_approves_followers: true,
Ok(()) discoverable: true,
actor_type: ApActorType::Person,
featured_url: None,
} }
} }
// ── FollowRepository ────────────────────────────────────────────────────────── fn build_user_repo(id: uuid::Uuid, username: &str) -> Arc<crate::testing::MockUserRepo> {
let user = make_user(id, username);
#[derive(Default)] let uname = username.to_string();
struct MemFollowRepo; let user2 = user.clone();
MockUserRepoBuilder::new()
#[async_trait] .on_find_by_id(move |qid| {
impl FollowRepository for MemFollowRepo { if qid == id {
async fn add_follower( Ok(Some(user.clone()))
&self, } else {
_: uuid::Uuid, Ok(None)
_: &str, }
_: FollowerStatus, })
_: &str, .on_find_by_username(move |name| {
) -> anyhow::Result<()> { if name == uname {
Ok(()) Ok(Some(user2.clone()))
} } else {
async fn get_follower_follow_activity_id( Ok(None)
&self, }
_: uuid::Uuid, })
_: &str, .build()
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn remove_follower(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_followers(&self, _: uuid::Uuid) -> anyhow::Result<Vec<Follower>> {
Ok(vec![])
}
async fn get_followers_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<Follower>> {
Ok(vec![])
}
async fn count_followers(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn update_follower_status(
&self,
_: uuid::Uuid,
_: &str,
_: FollowerStatus,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_pending_followers(&self, _: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_follower_inboxes(&self, _: uuid::Uuid) -> anyhow::Result<Vec<String>> {
Ok(vec![])
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_accepted_followers_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn add_following(&self, _: uuid::Uuid, _: RemoteActor, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_follow_activity_id(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn remove_following(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_following(&self, _: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_following_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn count_following(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn update_following_status(
&self,
_: uuid::Uuid,
_: &str,
_: FollowingStatus,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_following_outbox_url(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn migrate_follower_actor(&self, _: &str, _: &str) -> anyhow::Result<Vec<uuid::Uuid>> {
Ok(vec![])
}
} }
// ── ActorRepository ─────────────────────────────────────────────────────────── // ── Helper ───────────────────────────────────────────────────────────────────
#[derive(Default)]
struct MemActorRepo;
#[async_trait]
impl ActorRepository for MemActorRepo {
async fn get_local_actor_keypair(
&self,
_: uuid::Uuid,
) -> anyhow::Result<Option<(String, String)>> {
Ok(None)
}
async fn save_local_actor_keypair(
&self,
_: uuid::Uuid,
_: String,
_: String,
) -> anyhow::Result<()> {
Ok(())
}
async fn upsert_remote_actor(&self, _: RemoteActor) -> anyhow::Result<()> {
Ok(())
}
async fn get_remote_actor(&self, _: &str) -> anyhow::Result<Option<RemoteActor>> {
Ok(None)
}
async fn add_announce(
&self,
_: &str,
_: &str,
_: &str,
_: DateTime<Utc>,
) -> anyhow::Result<()> {
Ok(())
}
async fn remove_announce(&self, _: &str, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn count_announces(&self, _: &str) -> anyhow::Result<usize> {
Ok(0)
}
}
// ── BlocklistRepository ───────────────────────────────────────────────────────
struct MemBlocklistRepo {
blocked_domains: Mutex<HashSet<String>>,
}
impl MemBlocklistRepo {
fn with_blocked_domains(domains: impl IntoIterator<Item = String>) -> Self {
Self {
blocked_domains: Mutex::new(domains.into_iter().collect()),
}
}
}
impl Default for MemBlocklistRepo {
fn default() -> Self {
Self {
blocked_domains: Mutex::new(HashSet::new()),
}
}
}
#[async_trait]
impl BlocklistRepository for MemBlocklistRepo {
async fn add_blocked_domain(&self, domain: &str, _: Option<&str>) -> anyhow::Result<()> {
self.blocked_domains.lock().await.insert(domain.to_string());
Ok(())
}
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
self.blocked_domains.lock().await.remove(domain);
Ok(())
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![])
}
async fn is_domain_blocked(&self, domain: &str) -> anyhow::Result<bool> {
Ok(self.blocked_domains.lock().await.contains(domain))
}
async fn add_blocked_actor(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_actor(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_actors(&self, _: uuid::Uuid) -> anyhow::Result<Vec<String>> {
Ok(vec![])
}
async fn is_actor_blocked(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<bool> {
Ok(false)
}
}
// ── ApUserRepository ──────────────────────────────────────────────────────────
struct MemUserRepo {
users: HashMap<uuid::Uuid, ApUser>,
}
impl MemUserRepo {
fn with_user(id: uuid::Uuid, username: &str) -> Self {
let mut users = HashMap::new();
users.insert(
id,
ApUser {
id,
username: username.to_string(),
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: vec![],
profile_url: None,
attachment: vec![],
manually_approves_followers: true,
discoverable: true,
actor_type: ApActorType::Person,
featured_url: None,
},
);
Self { users }
}
}
#[async_trait]
impl ApUserRepository for MemUserRepo {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
Ok(self.users.get(&id).cloned())
}
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
Ok(self
.users
.values()
.find(|u| u.username == username)
.cloned())
}
async fn count_users(&self) -> anyhow::Result<usize> {
Ok(self.users.len())
}
}
// ── ApContentReader ───────────────────────────────────────────────────────────
#[derive(Default)]
struct MemContentReader;
#[async_trait]
impl ApContentReader for MemContentReader {
async fn get_local_objects_page(
&self,
_: uuid::Uuid,
_: Option<DateTime<Utc>>,
_: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
Ok(vec![])
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
Ok(0)
}
}
// ── ApObjectHandler ───────────────────────────────────────────────────────────
#[derive(Default)]
struct MemHandler {
creates: Mutex<Vec<Url>>,
mentions: Mutex<Vec<(Url, uuid::Uuid)>>,
}
#[async_trait]
impl ApObjectHandler for MemHandler {
async fn on_create(&self, ap_id: &Url, _: &Url, _: serde_json::Value) -> anyhow::Result<()> {
self.creates.lock().await.push(ap_id.clone());
Ok(())
}
async fn on_update(&self, _: &Url, _: &Url, _: serde_json::Value) -> anyhow::Result<()> {
Ok(())
}
async fn on_delete(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_actor_removed(&self, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_mention(&self, ap_id: &Url, user_id: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
self.mentions.lock().await.push((ap_id.clone(), user_id));
Ok(())
}
}
// ── Helper ────────────────────────────────────────────────────────────────────
fn make_data( fn make_data(
activity_repo: Arc<MemActivityRepo>, blocklist_repo: Option<Arc<crate::testing::MockBlocklistRepo>>,
follow_repo: Arc<MemFollowRepo>, user_repo: Arc<crate::testing::MockUserRepo>,
actor_repo: Arc<MemActorRepo>, handler: Arc<crate::testing::MockObjectHandler>,
blocklist_repo: Arc<MemBlocklistRepo>,
user_repo: Arc<MemUserRepo>,
content_reader: Arc<MemContentReader>,
handler: Arc<MemHandler>,
) -> FederationData { ) -> FederationData {
// Activity repo with real dedup tracking
let processed = Arc::new(Mutex::new(HashSet::<String>::new()));
let p1 = processed.clone();
let p2 = processed.clone();
let activity_repo = MockActivityRepoBuilder::new()
.on_is_activity_processed(move |id| Ok(p1.try_lock().unwrap().contains(id)))
.on_mark_activity_processed(move |id| {
p2.try_lock().unwrap().insert(id.to_string());
Ok(())
})
.build();
FederationData::new( FederationData::new(
activity_repo, activity_repo,
follow_repo, MockFollowRepoBuilder::new().build(),
actor_repo, MockActorRepoBuilder::new().build(),
blocklist_repo, blocklist_repo.unwrap_or_else(|| MockBlocklistRepoBuilder::new().build()),
user_repo, user_repo,
content_reader, MockContentReaderBuilder::new().build(),
handler, handler,
"https://example.com".to_string(), "https://example.com".to_string(),
false, false,
"test".to_string(), "test".to_string(),
None, None,
std::time::Duration::from_secs(24 * 60 * 60), std::time::Duration::from_secs(24 * 60 * 60),
Arc::new(crate::url_scheme::DefaultUrlScheme),
) )
} }
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn check_guards_idempotency() { async fn check_guards_idempotency() {
use crate::activities::helpers::check_guards; use crate::activities::helpers::check_guards;
use activitypub_federation::config::FederationConfig; use activitypub_federation::config::FederationConfig;
let activity_repo = Arc::new(MemActivityRepo::default());
let data_inner = make_data( let data_inner = make_data(
activity_repo, None,
Arc::new(MemFollowRepo), build_user_repo(uuid::Uuid::new_v4(), "alice"),
Arc::new(MemActorRepo), MockObjectHandlerBuilder::new().build(),
Arc::new(MemBlocklistRepo::default()),
Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")),
Arc::new(MemContentReader),
Arc::new(MemHandler::default()),
); );
let config = FederationConfig::builder() let config = FederationConfig::builder()
.domain("example.com") .domain("example.com")
@@ -423,17 +131,13 @@ async fn check_guards_blocks_domain() {
use crate::activities::helpers::check_guards; use crate::activities::helpers::check_guards;
use activitypub_federation::config::FederationConfig; use activitypub_federation::config::FederationConfig;
let blocklist_repo = Arc::new(MemBlocklistRepo::with_blocked_domains([ let blocklist = MockBlocklistRepoBuilder::new()
"spam.example".to_string() .on_is_domain_blocked(|domain| Ok(domain == "spam.example"))
])); .build();
let data_inner = make_data( let data_inner = make_data(
Arc::new(MemActivityRepo::default()), Some(blocklist),
Arc::new(MemFollowRepo), build_user_repo(uuid::Uuid::new_v4(), "alice"),
Arc::new(MemActorRepo), MockObjectHandlerBuilder::new().build(),
blocklist_repo,
Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")),
Arc::new(MemContentReader),
Arc::new(MemHandler::default()),
); );
let config = FederationConfig::builder() let config = FederationConfig::builder()
.domain("example.com") .domain("example.com")
@@ -457,16 +161,15 @@ async fn extract_and_dispatch_mentions_notifies_local_users() {
use activitypub_federation::config::FederationConfig; use activitypub_federation::config::FederationConfig;
let local_user_id = uuid::Uuid::new_v4(); let local_user_id = uuid::Uuid::new_v4();
let handler = Arc::new(MemHandler::default()); let mentions: Arc<Mutex<Vec<(Url, uuid::Uuid)>>> = Arc::new(Mutex::new(vec![]));
let data_inner = make_data( let m = mentions.clone();
Arc::new(MemActivityRepo::default()), let handler = MockObjectHandlerBuilder::new()
Arc::new(MemFollowRepo), .on_on_mention(move |ap_id, user_id, _| {
Arc::new(MemActorRepo), m.try_lock().unwrap().push((ap_id.clone(), user_id));
Arc::new(MemBlocklistRepo::default()), Ok(())
Arc::new(MemUserRepo::with_user(local_user_id, "alice")), })
Arc::new(MemContentReader), .build();
handler.clone(), let data_inner = make_data(None, build_user_repo(local_user_id, "alice"), handler);
);
let config = FederationConfig::builder() let config = FederationConfig::builder()
.domain("example.com") .domain("example.com")
.app_data(data_inner) .app_data(data_inner)
@@ -488,7 +191,7 @@ async fn extract_and_dispatch_mentions_notifies_local_users() {
extract_and_dispatch_mentions(&ap_id, &actor_url, &object, &data).await; extract_and_dispatch_mentions(&ap_id, &actor_url, &object, &data).await;
let mentions = handler.mentions.lock().await; let mentions = mentions.lock().await;
assert_eq!(mentions.len(), 1); assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].0, ap_id); assert_eq!(mentions[0].0, ap_id);
assert_eq!(mentions[0].1, local_user_id); assert_eq!(mentions[0].1, local_user_id);

63
src/url_scheme.rs Normal file
View File

@@ -0,0 +1,63 @@
use url::Url;
/// Defines how ActivityPub URLs are constructed for local actors.
///
/// Implement this trait to use custom URL patterns (e.g. `/@username`
/// instead of `/users/{uuid}`). The default implementation
/// [`DefaultUrlScheme`] preserves the original `/users/{uuid}` layout.
pub trait UrlScheme: Send + Sync {
fn actor_url(&self, base_url: &str, user_id: uuid::Uuid) -> anyhow::Result<Url>;
fn inbox_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
fn shared_inbox_url(&self, base_url: &str) -> Option<Url>;
fn outbox_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
fn followers_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
fn following_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
fn activity_url(&self, base_url: &str) -> anyhow::Result<Url>;
fn extract_user_id(&self, url: &Url) -> Option<uuid::Uuid>;
}
/// Default URL scheme: `/users/{uuid}` with sub-paths for inbox, outbox, etc.
pub struct DefaultUrlScheme;
impl UrlScheme for DefaultUrlScheme {
fn actor_url(&self, base_url: &str, user_id: uuid::Uuid) -> anyhow::Result<Url> {
Url::parse(&format!("{}/users/{}", base_url, user_id))
.map_err(|error| anyhow::anyhow!("invalid base_url: {error}"))
}
fn inbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
Url::parse(&format!("{}/inbox", actor_url))
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
}
fn shared_inbox_url(&self, base_url: &str) -> Option<Url> {
Url::parse(&format!("{}/inbox", base_url)).ok()
}
fn outbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
Url::parse(&format!("{}/outbox", actor_url))
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
}
fn followers_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
Url::parse(&format!("{}/followers", actor_url))
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
}
fn following_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
Url::parse(&format!("{}/following", actor_url))
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
}
fn activity_url(&self, base_url: &str) -> anyhow::Result<Url> {
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
.map_err(|error| anyhow::anyhow!("invalid base_url: {error}"))
}
fn extract_user_id(&self, url: &Url) -> Option<uuid::Uuid> {
let path = url.path();
path.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
}
}

View File

@@ -1,10 +1,8 @@
use url::Url;
use crate::error::Error;
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public"; pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
pub const AP_CONTENT_TYPE: &str = "application/activity+json";
pub const AP_PAGE_SIZE: usize = 20; pub const AP_PAGE_SIZE: usize = 20;
pub const INBOX_BODY_LIMIT: usize = 1024 * 1024;
/// Returns the `@context` array for actor AP JSON. /// Returns the `@context` array for actor AP JSON.
/// Includes the W3C security vocabulary (needed for `publicKey` resolution) /// Includes the W3C security vocabulary (needed for `publicKey` resolution)
@@ -23,29 +21,3 @@ pub fn actor_ap_context() -> serde_json::Value {
} }
]) ])
} }
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
let path = url.path();
path.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
}
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
}
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_id))
.expect("base_url is always a valid URL prefix")
}
/// Extract the username segment from a /users/:username URL.
#[allow(dead_code)]
pub fn extract_username_from_url(url: &Url) -> Option<String> {
url.path()
.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.map(|s| s.to_string())
}