diff --git a/CHANGELOG.md b/CHANGELOG.md index 5371f11..64304c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # 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` 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 ### New features diff --git a/Cargo.lock b/Cargo.lock index 7c9b094..107654d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "k-ap" -version = "0.4.6" +version = "0.5.0" dependencies = [ "activitypub_federation", "anyhow", @@ -1377,9 +1377,11 @@ dependencies = [ "chrono", "enum_delegate", "futures", + "paste", "reqwest", "serde", "serde_json", + "thiserror", "tokio", "tracing", "url", @@ -1596,6 +1598,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem-rfc7468" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index a365c13..0abb103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "k-ap" -version = "0.4.6" +version = "0.5.0" edition = "2024" description = "Generic ActivityPub protocol layer" license = "MIT" @@ -14,6 +14,7 @@ serde_json = "1.0" uuid = { version = "1.0", features = ["v4", "v5", "serde"] } chrono = { version = "0.4", features = ["serde"] } anyhow = "1.0" +thiserror = "2" tracing = "0.1" async-trait = "0.1" axum = { version = "0.8", features = ["macros"] } @@ -22,3 +23,4 @@ url = { version = "2", features = ["serde"] } enum_delegate = "0.2" activitypub_federation = "0.7.0-beta.11" zeroize = { version = "1", features = ["derive"] } +paste = "1" diff --git a/README.md b/README.md index 799e6fb..214bab5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Via the private Gitea registry (recommended): ```toml [dependencies] -k-ap = { version = "0.3.0", registry = "gitea" } +k-ap = { version = "0.5.0", registry = "gitea" } ``` Configure the registry in `.cargo/config.toml`: @@ -26,21 +26,21 @@ Or via git if you don't have registry access: ```toml [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 -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 // Activity deduplication — idempotency for inbound deliveries impl ActivityRepository for MyDb { ... } -// Follower / following graph + account migration +// Follower / following graph + account migration (5 sub-traits) 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 { ... } // Domain and per-user actor blocklists @@ -56,6 +56,8 @@ impl ApContentReader 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 ```rust @@ -74,6 +76,7 @@ let service = ActivityPubService::builder("https://example.com") .object_handler(db.clone()) .allow_registration(false) .software_name("my-app") + // .url_scheme(Arc::new(MyScheme)) // optional: customize URL patterns .build() .await?; @@ -149,15 +152,15 @@ let mentioned = vec![ ]; // 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 -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 -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?; // Announce / Undo Announce @@ -173,6 +176,9 @@ service.broadcast_actor_update(user_id).await?; // Account migration — sends Move to all followers; set alsoKnownAs first 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 @@ -287,22 +293,87 @@ Handled out of the box: |------|-------------| | `ActivityPubService` | Central service — build once, share via `Arc` | | `ActivityRepository` | Trait: activity ID deduplication (2 methods) | -| `FollowRepository` | Trait: follower/following graph + migration (18 methods) | -| `ActorRepository` | Trait: keypairs, remote actor cache, announce tracking (6 methods) | +| `FollowRepository` | Supertrait: follower/following graph + migration (19 methods via 5 sub-traits) | +|  `FollowerWriter` | Sub-trait: add/remove/update follower records (4 methods) | +|  `FollowerReader` | Sub-trait: query followers, counts, pages (7 methods) | +|  `FollowingWriter` | Sub-trait: add/remove/update following records (4 methods) | +|  `FollowingReader` | Sub-trait: query following, counts, pages (3 methods) | +|  `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) | +|  `KeypairRepository` | Sub-trait: local actor keypair storage (2 methods) | +|  `RemoteActorCache` | Sub-trait: remote actor upsert/lookup (2 methods) | +|  `AnnounceRepository` | Sub-trait: boost tracking (3 methods) | | `BlocklistRepository` | Trait: domain and actor blocklists (8 methods) | | `ApUserRepository` | Trait: user lookup (3 methods) | | `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` | | `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 | | `LookedUpActor` | Resolved remote actor from `lookup_actor_by_handle` | | `RemoteActor` | Cached federated actor record | | `Follower` / `FollowerStatus` | Follower with `Pending`/`Accepted`/`Rejected` state | | `ApUser` | AP-serializable local user | | `ApFederationConfig` | Wraps the `activitypub_federation` config | -| `Error` | AP-layer error type | +| `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 { + // e.g. https://example.com/@username instead of /users/{uuid} + todo!() + } + fn inbox_url(&self, actor_url: &Url) -> anyhow::Result { todo!() } + fn shared_inbox_url(&self, base_url: &str) -> Option { todo!() } + fn outbox_url(&self, actor_url: &Url) -> anyhow::Result { todo!() } + fn followers_url(&self, actor_url: &Url) -> anyhow::Result { todo!() } + fn following_url(&self, actor_url: &Url) -> anyhow::Result { todo!() } + fn activity_url(&self, base_url: &str) -> anyhow::Result { todo!() } + fn extract_user_id(&self, url: &Url) -> Option { 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 diff --git a/src/activities/accept.rs b/src/activities/accept.rs index 849ff06..8f6d7dc 100644 --- a/src/activities/accept.rs +++ b/src/activities/accept.rs @@ -36,9 +36,9 @@ impl Activity for AcceptActivity { async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { if self.actor.inner() != self.object.object.inner() { - return Err(Error::bad_request(anyhow::anyhow!( - "Accept actor does not match Follow target" - ))); + return Err(Error::bad_request( + "Accept actor does not match Follow target", + )); } Ok(()) } @@ -47,8 +47,10 @@ impl Activity for AcceptActivity { if check_guards(&self.id, self.actor.inner(), data).await? { return Ok(()); } - let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner()) - .ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?; + let local_user_id = data + .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(); data.follow_repo .update_following_status(local_user_id, &remote_actor_url, FollowingStatus::Accepted) @@ -62,14 +64,17 @@ impl Activity for AcceptActivity { .await .ok() .flatten() - .and_then(|a| a.outbox_url); - let _ = publisher + .and_then(|actor| actor.outbox_url); + if let Err(error) = publisher .publish(crate::data::FederationEvent::OutboundFollowAccepted { local_user_id, remote_actor_url, outbox_url, }) - .await; + .await + { + tracing::warn!(%error, "failed to publish OutboundFollowAccepted event"); + } } Ok(()) } diff --git a/src/activities/add.rs b/src/activities/add.rs index 9076b91..22e6209 100644 --- a/src/activities/add.rs +++ b/src/activities/add.rs @@ -6,7 +6,7 @@ use crate::actors::DbActor; use crate::data::FederationData; 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)] #[serde(rename = "Add")] @@ -39,34 +39,18 @@ impl Activity for AddActivity { } async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { - if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) - && 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(()) + verify_attributed_to(&self.object, self.actor.inner(), "Add") } async fn receive(self, data: &Data) -> Result<(), Self::Error> { if check_guards(&self.id, self.actor.inner(), data).await? { return Ok(()); } - // Use the object's own id as the stable AP identifier, falling back to - // 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 ap_id = extract_object_ap_id(&self.object, &self.id); let actor_url = self.actor.inner().clone(); data.object_handler .on_create(&ap_id, &actor_url, self.object) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + .await?; tracing::info!(actor = %actor_url, "received Add activity"); Ok(()) } diff --git a/src/activities/block.rs b/src/activities/block.rs index db19a02..61a0db5 100644 --- a/src/activities/block.rs +++ b/src/activities/block.rs @@ -47,19 +47,28 @@ impl Activity for BlockActivity { return Ok(()); } let actor_url = self.actor.inner().as_str(); - if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) { - let _ = data + if let Some(local_user_id) = data.url_scheme.extract_user_id(&self.object) { + if let Err(error) = data .follow_repo .remove_following(local_user_id, actor_url) - .await; - let _ = data + .await + { + tracing::debug!(%error, "following already removed"); + } + if let Err(error) = data .follow_repo .remove_follower(local_user_id, actor_url) - .await; - let _ = data + .await + { + tracing::debug!(%error, "follower already removed"); + } + if let Err(error) = data .blocklist_repo .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"); Ok(()) diff --git a/src/activities/create.rs b/src/activities/create.rs index 8a677df..e7c27bf 100644 --- a/src/activities/create.rs +++ b/src/activities/create.rs @@ -8,7 +8,9 @@ use crate::actors::DbActor; use crate::data::FederationData; 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)] #[serde(rename_all = "camelCase")] @@ -41,33 +43,19 @@ impl Activity for CreateActivity { } async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { - if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) - && 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(()) + verify_attributed_to(&self.object, self.actor.inner(), "Create") } async fn receive(self, data: &Data) -> Result<(), Self::Error> { if check_guards(&self.id, self.actor.inner(), data).await? { return Ok(()); } - 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 ap_id = extract_object_ap_id(&self.object, &self.id); let actor_url = self.actor.inner().clone(); extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await; data.object_handler .on_create(&ap_id, &actor_url, self.object) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + .await?; tracing::info!(actor = %actor_url, "received create activity"); Ok(()) } diff --git a/src/activities/delete.rs b/src/activities/delete.rs index 9093dfb..1d176d0 100644 --- a/src/activities/delete.rs +++ b/src/activities/delete.rs @@ -52,9 +52,9 @@ impl Activity for DeleteActivity { _ => String::new(), }; if !object_domain.is_empty() && actor_domain != object_domain { - return Err(Error::bad_request(anyhow::anyhow!( - "Delete actor domain does not match object domain" - ))); + return Err(Error::bad_request( + "Delete actor domain does not match object domain", + )); } Ok(()) } @@ -78,17 +78,13 @@ impl Activity for DeleteActivity { return Ok(()); }; if object_url == *self.actor.inner() { - data.object_handler - .on_actor_removed(&actor_url) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + data.object_handler.on_actor_removed(&actor_url).await?; tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted"); return Ok(()); } data.object_handler .on_delete(&object_url, &actor_url) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + .await?; tracing::info!(object = %object_url, "received Delete(note)"); Ok(()) } diff --git a/src/activities/follow.rs b/src/activities/follow.rs index 6573a8f..02a8a78 100644 --- a/src/activities/follow.rs +++ b/src/activities/follow.rs @@ -39,15 +39,13 @@ impl Activity for FollowActivity { (Some(host), Some(port)) => format!("{}:{}", host, port), (Some(host), None) => host.to_string(), _ => { - return Err(Error::bad_request(anyhow::anyhow!( - "invalid follow target URL" - ))); + return Err(Error::bad_request("invalid follow target URL")); } }; if target_domain == data.domain { 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 .user_repo .find_by_id(uuid) @@ -59,9 +57,7 @@ impl Activity for FollowActivity { tracing::debug!(target = %target_url, "accepting follow for migrated actor URL"); return Ok(()); } - Err(Error::bad_request(anyhow::anyhow!( - "follow target is not a local actor" - ))) + Err(Error::bad_request("follow target is not a local actor")) } async fn receive(self, data: &Data) -> Result<(), Self::Error> { @@ -69,7 +65,7 @@ impl Activity for FollowActivity { return Ok(()); } // 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 .blocklist_repo .is_actor_blocked(target_user_id, self.actor.inner().as_str()) diff --git a/src/activities/helpers.rs b/src/activities/helpers.rs index 81614e3..bba725b 100644 --- a/src/activities/helpers.rs +++ b/src/activities/helpers.rs @@ -47,6 +47,53 @@ pub(crate) async fn check_guards( 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. /// Failures are logged and never propagated — a broken mention must not fail the activity. pub(crate) async fn extract_and_dispatch_mentions( @@ -55,7 +102,7 @@ pub(crate) async fn extract_and_dispatch_mentions( object: &serde_json::Value, data: &Data, ) { - 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; }; for tag in tags { @@ -68,7 +115,7 @@ pub(crate) async fn extract_and_dispatch_mentions( let Ok(href_url) = Url::parse(href) else { 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; }; if let Err(e) = data diff --git a/src/activities/like.rs b/src/activities/like.rs index a6b47ec..b4b766f 100644 --- a/src/activities/like.rs +++ b/src/activities/like.rs @@ -57,8 +57,7 @@ impl Activity for LikeActivity { } data.object_handler .on_like(&self.object, self.actor.inner()) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + .await?; tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like"); Ok(()) } diff --git a/src/activities/mod.rs b/src/activities/mod.rs index f75217f..86a688d 100644 --- a/src/activities/mod.rs +++ b/src/activities/mod.rs @@ -1,26 +1,26 @@ mod accept; mod add; -mod announce; -mod block; +pub(crate) mod announce; +pub(crate) mod block; mod create; mod delete; mod follow; pub(crate) mod helpers; -mod like; +pub(crate) mod like; mod move_act; mod reject; mod undo; mod update; pub use accept::AcceptActivity; -pub use add::{AddActivity, AddType}; -pub use announce::{AnnounceActivity, AnnounceType}; -pub use block::{BlockActivity, BlockType}; +pub use add::AddActivity; +pub use announce::AnnounceActivity; +pub use block::BlockActivity; pub use create::CreateActivity; pub use delete::DeleteActivity; pub use follow::FollowActivity; -pub use like::{LikeActivity, LikeType}; -pub use move_act::{MoveActivity, MoveType}; +pub use like::LikeActivity; +pub use move_act::MoveActivity; pub use reject::RejectActivity; pub use undo::UndoActivity; pub use update::UpdateActivity; diff --git a/src/activities/move_act.rs b/src/activities/move_act.rs index 858fd71..264e543 100644 --- a/src/activities/move_act.rs +++ b/src/activities/move_act.rs @@ -41,9 +41,7 @@ impl Activity for MoveActivity { async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { if &self.object != self.actor.inner() { - return Err(Error::bad_request(anyhow::anyhow!( - "Move object must be the actor itself" - ))); + return Err(Error::bad_request("Move object must be the actor itself")); } Ok(()) } @@ -52,23 +50,21 @@ impl Activity for MoveActivity { if check_guards(&self.id, self.actor.inner(), data).await? { return Ok(()); } + let target = ObjectId::::from(self.target.clone()) .dereference(data) - .await - .map_err(|e| Error::from(anyhow::anyhow!("{e}")))?; - // Verify the new actor claims the old identity via alsoKnownAs. - // The spec allows multiple aliases; check all of them. + .await?; let old_url = self.object.as_str(); if !target.also_known_as.iter().any(|a| a == old_url) { - return Err(Error::bad_request(anyhow::anyhow!( - "Move target alsoKnownAs does not reference old actor" - ))); + return Err(Error::bad_request( + "Move target alsoKnownAs does not reference old actor", + )); } + let affected = data .follow_repo .migrate_follower_actor(old_url, self.target.as_str()) - .await - .map_err(|e| Error::from(anyhow::anyhow!("{e}")))?; + .await?; let affected_count = affected.len(); // 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(); tokio::spawn(async move { for local_user_id in &affected { - let local_actor = - match crate::actors::get_local_actor(*local_user_id, &data_clone).await { - Ok(a) => a, - Err(e) => { - tracing::warn!( - 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()], + if let Err(e) = send_refollow( + *local_user_id, + &target_url, + &target_inbox, + &base_url, &data_clone, ) .await { - Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "Move: failed to prepare re-follow"); - continue; - } - }; - for send in sends { - if let Err(e) = send.sign_and_send(&data_clone).await { - tracing::warn!( - error = %e, - %local_user_id, - "Move: re-follow delivery failed" - ); - } + tracing::warn!( + error = %e, + %local_user_id, + "Move: re-follow failed" + ); } } }); @@ -139,3 +102,40 @@ impl Activity for MoveActivity { Ok(()) } } + +async fn send_refollow( + local_user_id: uuid::Uuid, + new_target_url: &Url, + new_target_inbox: &Url, + base_url: &str, + data: &Data, +) -> 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(()) +} diff --git a/src/activities/reject.rs b/src/activities/reject.rs index bae1a3f..00dd315 100644 --- a/src/activities/reject.rs +++ b/src/activities/reject.rs @@ -35,9 +35,9 @@ impl Activity for RejectActivity { async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { if self.actor.inner() != self.object.object.inner() { - return Err(Error::bad_request(anyhow::anyhow!( - "Reject actor does not match Follow target" - ))); + return Err(Error::bad_request( + "Reject actor does not match Follow target", + )); } Ok(()) } @@ -46,7 +46,7 @@ impl Activity for RejectActivity { if check_guards(&self.id, self.actor.inner(), data).await? { 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 .remove_following(user_id, self.actor.inner().as_str()) .await?; diff --git a/src/activities/undo.rs b/src/activities/undo.rs index ba73717..67fee70 100644 --- a/src/activities/undo.rs +++ b/src/activities/undo.rs @@ -36,9 +36,9 @@ impl Activity for UndoActivity { if let Some(inner_actor) = self.object.get("actor").and_then(|v| v.as_str()) && inner_actor != self.actor.inner().as_str() { - return Err(Error::bad_request(anyhow::anyhow!( - "Undo actor does not match inner activity actor" - ))); + return Err(Error::bad_request( + "Undo actor does not match inner activity actor", + )); } Ok(()) } @@ -50,98 +50,14 @@ impl Activity for UndoActivity { let obj_type = self .object .get("type") - .and_then(|t| t.as_str()) + .and_then(|type_value| type_value.as_str()) .unwrap_or(""); match obj_type { - "Follow" => { - 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) - { - 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" - ); - } + "Follow" => handle_undo_follow(self.actor.inner(), &self.object, data).await?, + "Add" => handle_undo_add(self.actor.inner(), &self.object, data).await?, + "Like" => handle_undo_like(self.actor.inner(), &self.object, data).await?, + "Announce" => handle_undo_announce(self.actor.inner(), &self.object, data).await?, + "Block" => handle_undo_block(self.actor.inner(), &self.object, data).await?, other => { tracing::debug!(kind = %other, "ignoring Undo of unknown activity type"); } @@ -149,3 +65,117 @@ impl Activity for UndoActivity { Ok(()) } } + +async fn handle_undo_follow( + actor: &Url, + object: &serde_json::Value, + data: &Data, +) -> 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, +) -> 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, +) -> 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, +) -> 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, +) -> 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(()) +} diff --git a/src/activities/update.rs b/src/activities/update.rs index d95c874..25cdfce 100644 --- a/src/activities/update.rs +++ b/src/activities/update.rs @@ -8,7 +8,9 @@ use crate::actors::DbActor; use crate::data::FederationData; 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)] #[serde(rename_all = "camelCase")] @@ -37,33 +39,19 @@ impl Activity for UpdateActivity { } async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { - if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str()) - && 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(()) + verify_attributed_to(&self.object, self.actor.inner(), "Update") } async fn receive(self, data: &Data) -> Result<(), Self::Error> { if check_guards(&self.id, self.actor.inner(), data).await? { return Ok(()); } - 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 ap_id = extract_object_ap_id(&self.object, &self.id); let actor_url = self.actor.inner().clone(); extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await; data.object_handler .on_update(&ap_id, &actor_url, self.object) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + .await?; tracing::info!(actor = %actor_url, "received update activity"); Ok(()) } diff --git a/src/actors.rs b/src/actors.rs deleted file mode 100644 index 6b7817c..0000000 --- a/src/actors.rs +++ /dev/null @@ -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, - 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, - pub inbox_url: Url, - pub shared_inbox_url: Option, - pub outbox_url: Url, - pub followers_url: Url, - pub following_url: Url, - pub ap_id: Url, - pub last_refreshed_at: DateTime, - pub bio: Option, - pub avatar_url: Option, - pub banner_url: Option, - pub also_known_as: Vec, - pub profile_url: Option, - pub attachment: Vec, - pub manually_approves_followers: bool, - pub discoverable: bool, - pub actor_type: ApActorType, - pub featured_url: Option, -} - -#[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, - #[serde(default)] - preferred_username: String, - inbox: Url, - #[serde(default)] - outbox: Option, - #[serde(default)] - followers: Option, - #[serde(default)] - following: Option, - pub public_key: PublicKey, - #[serde(default)] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - discoverable: Option, - #[serde(default)] - manually_approves_followers: bool, - #[serde(skip_serializing_if = "Option::is_none", default)] - updated: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - endpoints: Option, - #[serde(skip_serializing_if = "Option::is_none")] - image: Option, - #[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)] - also_known_as: Vec, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - attachment: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - featured: Option, -} - -struct ActorUrls { - ap_id: Url, - inbox_url: Url, - shared_inbox_url: Option, - 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, -) -> Result { - 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 { - 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> { - Some(self.last_refreshed_at) - } - - async fn read_from_id( - object_id: Url, - data: &Data, - ) -> Result, 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) -> Result { - 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 = 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, - ) -> 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) -> Result { - 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 { - self.private_key_pem.clone() - } - - fn inbox(&self) -> Url { - self.inbox_url.clone() - } -} - -#[cfg(test)] -#[path = "tests/actors.rs"] -mod tests; diff --git a/src/actors/mod.rs b/src/actors/mod.rs new file mode 100644 index 0000000..e2cecf2 --- /dev/null +++ b/src/actors/mod.rs @@ -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, +) -> Result { + 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 { + 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 { + self.private_key_pem.clone() + } + + fn inbox(&self) -> Url { + self.inbox_url.clone() + } +} + +#[cfg(test)] +#[path = "tests/actors.rs"] +mod tests; diff --git a/src/actors/person.rs b/src/actors/person.rs new file mode 100644 index 0000000..b7b4e31 --- /dev/null +++ b/src/actors/person.rs @@ -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> { + Some(self.last_refreshed_at) + } + + async fn read_from_id( + object_id: Url, + data: &Data, + ) -> Result, 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) -> Result { + 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 = 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, + ) -> 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) -> Result { + 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, + }) + } +} diff --git a/src/tests/actors.rs b/src/actors/tests/actors.rs similarity index 99% rename from src/tests/actors.rs rename to src/actors/tests/actors.rs index 8575d0d..ecd6d20 100644 --- a/src/tests/actors.rs +++ b/src/actors/tests/actors.rs @@ -1,3 +1,4 @@ +use super::types::{ApImageObject, Endpoints}; use super::*; // ── Person AP JSON serialization ────────────────────────────────────────────── diff --git a/src/actors/types.rs b/src/actors/types.rs new file mode 100644 index 0000000..2a20918 --- /dev/null +++ b/src/actors/types.rs @@ -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, + 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, + pub inbox_url: Url, + pub shared_inbox_url: Option, + pub outbox_url: Url, + pub followers_url: Url, + pub following_url: Url, + pub ap_id: Url, + pub last_refreshed_at: DateTime, + pub bio: Option, + pub avatar_url: Option, + pub banner_url: Option, + pub also_known_as: Vec, + pub profile_url: Option, + pub attachment: Vec, + pub manually_approves_followers: bool, + pub discoverable: bool, + pub actor_type: ApActorType, + pub featured_url: Option, +} + +impl DbActor { + pub fn object_id(&self) -> ObjectId { + 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, + 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 { + 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, + #[serde(default)] + pub(crate) preferred_username: String, + pub(crate) inbox: Url, + #[serde(default)] + pub(crate) outbox: Option, + #[serde(default)] + pub(crate) followers: Option, + #[serde(default)] + pub(crate) following: Option, + pub public_key: activitypub_federation::protocol::public_key::PublicKey, + #[serde(default)] + pub(crate) name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) discoverable: Option, + #[serde(default)] + pub(crate) manually_approves_followers: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(crate) updated: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) endpoints: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) image: Option, + #[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)] + pub(crate) also_known_as: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) attachment: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) featured: Option, +} diff --git a/src/content.rs b/src/content.rs index b5acce8..b4af78e 100644 --- a/src/content.rs +++ b/src/content.rs @@ -2,6 +2,17 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use url::Url; +#[derive(Debug, Clone)] +pub struct LocalObject { + pub ap_id: Url, + pub object: serde_json::Value, + pub published_at: DateTime, + pub to: Vec, + pub cc: Vec, + pub bto: Vec, + pub bcc: Vec, +} + /// Read side — the library queries this when sending content outward. /// Implement on the same struct as [`ApObjectHandler`] if you prefer a single /// database type. @@ -9,7 +20,6 @@ use url::Url; pub trait ApContentReader: Send + Sync { /// Newest-first page of locally-authored objects for `user_id`, published /// 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 /// accepted. Implementations MUST: @@ -21,7 +31,7 @@ pub trait ApContentReader: Send + Sync { user_id: uuid::Uuid, before: Option>, limit: usize, - ) -> anyhow::Result)>>; + ) -> anyhow::Result>; /// Total locally-authored posts across all users. Used by NodeInfo. async fn count_local_posts(&self) -> anyhow::Result; @@ -91,7 +101,7 @@ pub trait ApObjectHandler: Send + Sync { /// A remote actor boosted (Announced) a **locally-authored** object. /// /// `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<()>; /// 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, actor_url: &Url, ) -> 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(()) + } } diff --git a/src/data.rs b/src/data.rs index 563550e..965900b 100644 --- a/src/data.rs +++ b/src/data.rs @@ -4,6 +4,7 @@ use crate::content::{ApContentReader, ApObjectHandler}; use crate::repository::{ ActivityRepository, ActorRepository, BlocklistRepository, FollowRepository, }; +use crate::url_scheme::UrlScheme; use crate::user::ApUserRepository; /// Typed event emitted by the federation layer. @@ -71,6 +72,7 @@ pub struct FederationData { pub(crate) software_name: String, pub(crate) event_publisher: Option>, pub(crate) actor_cache_ttl: std::time::Duration, + pub(crate) url_scheme: Arc, pub(crate) nodeinfo_services_inbound: Vec, pub(crate) nodeinfo_services_outbound: Vec, pub(crate) nodeinfo_metadata: serde_json::Value, @@ -78,7 +80,7 @@ pub struct FederationData { impl FederationData { #[allow(clippy::too_many_arguments)] - pub fn new( + pub(crate) fn new( activity_repo: Arc, follow_repo: Arc, actor_repo: Arc, @@ -91,6 +93,7 @@ impl FederationData { software_name: String, event_publisher: Option>, actor_cache_ttl: std::time::Duration, + url_scheme: Arc, ) -> Self { let domain = base_url .trim_start_matches("https://") @@ -113,6 +116,7 @@ impl FederationData { software_name, event_publisher, actor_cache_ttl, + url_scheme, nodeinfo_services_inbound: vec![], nodeinfo_services_outbound: vec![], nodeinfo_metadata: serde_json::json!({}), diff --git a/src/error.rs b/src/error.rs index 709cb45..171e11d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,44 +1,70 @@ -use std::fmt::{Display, Formatter}; - use axum::http::StatusCode; -#[derive(Debug)] -pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode); +#[derive(Debug, thiserror::Error)] +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 { - pub fn not_found(e: impl Into) -> Self { - Self(e.into(), StatusCode::NOT_FOUND) + pub fn not_found(message: impl Into) -> Self { + Self::NotFound(message.into()) } - pub fn bad_request(e: impl Into) -> Self { - Self(e.into(), StatusCode::BAD_REQUEST) + pub fn bad_request(message: impl Into) -> Self { + Self::BadRequest(message.into()) + } + + pub fn unauthorized(message: impl Into) -> Self { + Self::Unauthorized(message.into()) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::Forbidden(message.into()) } } -impl Display for Error { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - std::fmt::Display::fmt(&self.0, f) - } -} +impl From for Error { + fn from(error: activitypub_federation::error::Error) -> Self { + use activitypub_federation::error::Error as FedError; -impl From for Error -where - T: Into, -{ - fn from(t: T) -> Self { - Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR) + match &error { + FedError::ActivitySignatureInvalid | FedError::ActivityBodyDigestInvalid => { + Self::Unauthorized(error.to_string()) + } + _ => Self::Internal(error.into()), + } } } impl axum::response::IntoResponse for Error { fn into_response(self) -> axum::response::Response { - let status = self.1; - // Always log the real error internally; never expose it to the client. + let status = match &self { + 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() { - tracing::error!(error = %self.0, status = status.as_u16(), "federation error"); + tracing::error!(error = %self, status = status.as_u16(), "federation error"); } 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 { StatusCode::NOT_FOUND => "not found", StatusCode::BAD_REQUEST => "bad request", diff --git a/src/federation.rs b/src/federation.rs index c71f514..33c899f 100644 --- a/src/federation.rs +++ b/src/federation.rs @@ -61,11 +61,15 @@ impl ApFederationConfig { Ok(Self(config)) } + fn inner(&self) -> &FederationConfig { + &self.0 + } + pub fn to_request_data(&self) -> Data { - self.0.to_request_data() + self.inner().to_request_data() } pub fn middleware(&self) -> FederationMiddleware { - FederationMiddleware::new(self.0.clone()) + FederationMiddleware::new(self.inner().clone()) } } diff --git a/src/followers_handler.rs b/src/followers_handler.rs deleted file mode 100644 index 26afb4b..0000000 --- a/src/followers_handler.rs +++ /dev/null @@ -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, -} - -async fn collection_handler( - user_id_str: &str, - query: PageQuery, - data: Data, - collection_type: &str, -) -> Result, 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 = 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, - Query(query): Query, - data: Data, -) -> Result, Error> { - collection_handler(&user_id_str, query, data, "followers").await -} - -pub async fn following_handler( - Path(user_id_str): Path, - Query(query): Query, - data: Data, -) -> Result, Error> { - collection_handler(&user_id_str, query, data, "following").await -} diff --git a/src/actor_handler.rs b/src/handlers/actor.rs similarity index 85% rename from src/actor_handler.rs rename to src/handlers/actor.rs index 99f5bad..5d49ddd 100644 --- a/src/actor_handler.rs +++ b/src/handlers/actor.rs @@ -14,8 +14,8 @@ pub async fn actor_handler( Path(user_id_str): Path, data: Data, ) -> Result>, Error> { - let user_id = uuid::Uuid::parse_str(&user_id_str) - .map_err(|_| Error::not_found(anyhow::anyhow!("user not found")))?; + let user_id = + 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 person = db_actor.into_json(&data).await?; diff --git a/src/featured_handler.rs b/src/handlers/featured.rs similarity index 67% rename from src/featured_handler.rs rename to src/handlers/featured.rs index 23e110e..5d62b3e 100644 --- a/src/featured_handler.rs +++ b/src/handlers/featured.rs @@ -16,27 +16,22 @@ pub async fn featured_handler( Path(user_id_str): Path, data: Data, ) -> Result, Error> { - let user_id = uuid::Uuid::parse_str(&user_id_str) - .map_err(|_| Error::not_found(anyhow::anyhow!("user not found")))?; + let user_id = + uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::not_found("user not found"))?; data.user_repo .find_by_id(user_id) - .await - .map_err(Error::from)? - .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; + .await? + .ok_or_else(|| Error::not_found("user not found"))?; let featured_url = format!("{}/users/{}/featured", data.base_url, user_id_str); - let items = data - .content_reader - .get_featured_objects(user_id) - .await - .map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?; + let items = data.content_reader.get_featured_objects(user_id).await?; Ok(FederationJson(json!({ "@context": AP_CONTEXT, "type": "OrderedCollection", "id": featured_url, "totalItems": items.len(), - "orderedItems": items.iter().map(|u| u.as_str()).collect::>(), + "orderedItems": items.iter().map(|url| url.as_str()).collect::>(), }))) } diff --git a/src/handlers/followers.rs b/src/handlers/followers.rs new file mode 100644 index 0000000..70c14c3 --- /dev/null +++ b/src/handlers/followers.rs @@ -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, +} + +async fn collection_handler( + user_id_str: &str, + query: PageQuery, + data: Data, + collection_type: &str, +) -> Result, 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, + Query(query): Query, + data: Data, +) -> Result, Error> { + collection_handler(&user_id_str, query, data, "followers").await +} + +pub async fn following_handler( + Path(user_id_str): Path, + Query(query): Query, + data: Data, +) -> Result, Error> { + collection_handler(&user_id_str, query, data, "following").await +} diff --git a/src/handlers/inbox.rs b/src/handlers/inbox.rs new file mode 100644 index 0000000..148a771 --- /dev/null +++ b/src/handlers/inbox.rs @@ -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, + activity_data: ActivityData, +) -> Result<(), Error> { + let result = receive_activity::, 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") +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..575ab14 --- /dev/null +++ b/src/handlers/mod.rs @@ -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; diff --git a/src/nodeinfo.rs b/src/handlers/nodeinfo.rs similarity index 100% rename from src/nodeinfo.rs rename to src/handlers/nodeinfo.rs diff --git a/src/handlers/outbox.rs b/src/handlers/outbox.rs new file mode 100644 index 0000000..b76144d --- /dev/null +++ b/src/handlers/outbox.rs @@ -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, + before: Option, +} + +#[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(skip_serializing_if = "Option::is_none")] + next: Option, +} + +pub async fn outbox_handler( + Path(user_id_str): Path, + Query(query): Query, + data: Data, +) -> Result { + 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, +) -> Result { + let before: Option> = 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 { + 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, 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() +} diff --git a/src/tests/nodeinfo.rs b/src/handlers/tests/nodeinfo.rs similarity index 100% rename from src/tests/nodeinfo.rs rename to src/handlers/tests/nodeinfo.rs diff --git a/src/webfinger.rs b/src/handlers/webfinger.rs similarity index 81% rename from src/webfinger.rs rename to src/handlers/webfinger.rs index 15b2881..b5f394a 100644 --- a/src/webfinger.rs +++ b/src/handlers/webfinger.rs @@ -40,14 +40,13 @@ pub async fn webfinger_handler( let user = data .user_repo .find_by_username(name) - .await - .map_err(Error::from)? - .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; + .await? + .ok_or_else(|| Error::not_found("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 wf = WebfingerResponse { + let response = WebfingerResponse { subject: query.resource.clone(), aliases: vec![acct_uri, ap_id.to_string()], links: vec![ @@ -58,12 +57,12 @@ pub async fn webfinger_handler( }, WebfingerLink { 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()), }, ], }; - 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()) } diff --git a/src/inbox.rs b/src/inbox.rs deleted file mode 100644 index 21b1b50..0000000 --- a/src/inbox.rs +++ /dev/null @@ -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, - activity_data: ActivityData, -) -> Result<(), Error> { - receive_activity::, DbActor, FederationData>(activity_data, &data) - .await -} diff --git a/src/lib.rs b/src/lib.rs index 7ff3447..61ecd02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,33 +1,35 @@ -pub mod activities; -pub mod actor_handler; -pub mod actors; -pub mod content; -pub mod data; -pub mod error; -pub mod featured_handler; -pub mod federation; -pub mod followers_handler; -pub mod inbox; -pub mod nodeinfo; -pub mod outbox; +pub(crate) mod activities; +pub(crate) mod actors; +pub(crate) mod content; +pub(crate) mod data; +pub(crate) mod error; +pub(crate) mod federation; +pub(crate) mod handlers; pub mod repository; pub(crate) mod security; 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 mod user; -pub mod webfinger; +pub(crate) mod user; -pub use activitypub_federation::kinds::object::NoteType; -pub use content::{ApContentReader, ApObjectHandler}; +pub use content::{ApContentReader, ApObjectHandler, LocalObject}; pub use data::{EventPublisher, FederationData, FederationEvent}; pub use error::Error; pub use federation::ApFederationConfig; +pub use handlers::actor::actor_handler; +pub use handlers::followers::{followers_handler, following_handler}; pub use repository::{ - ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, - Follower, FollowerStatus, FollowingStatus, RemoteActor, + ActivityRepository, ActorBlocklist, ActorRepository, AnnounceRepository, BlockedDomain, + BlocklistRepository, DomainBlocklist, FollowMigration, FollowRepository, Follower, + FollowerReader, FollowerStatus, FollowerWriter, FollowingReader, FollowingStatus, + FollowingWriter, Keypair, KeypairRepository, RemoteActor, RemoteActorCache, }; 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::{ ApActorType, ApProfileField, ApUser, ApUserRepository, ApVisibility, LookedUpActor, }; diff --git a/src/outbox.rs b/src/outbox.rs deleted file mode 100644 index 767a488..0000000 --- a/src/outbox.rs +++ /dev/null @@ -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, - before: Option, -} - -#[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(skip_serializing_if = "Option::is_none")] - next: Option, -} - -pub async fn outbox_handler( - Path(user_id_str): Path, - Query(query): Query, - data: Data, -) -> Result { - 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> = 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 = 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()) - } -} diff --git a/src/repository/actor.rs b/src/repository/actor.rs index 24fb011..59d3db2 100644 --- a/src/repository/actor.rs +++ b/src/repository/actor.rs @@ -1,37 +1,5 @@ -use anyhow::Result; -use async_trait::async_trait; - -use super::RemoteActor; +use super::{AnnounceRepository, KeypairRepository, RemoteActorCache}; /// Manages local actor keypairs, remote actor cache, and Announce tracking. -#[async_trait] -pub trait ActorRepository: Send + Sync { - // ── Local keypairs ────────────────────────────────────────────────────── - async fn get_local_actor_keypair( - &self, - user_id: uuid::Uuid, - ) -> Result>; - 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>; - - // ── Boost (Announce) tracking ─────────────────────────────────────────── - async fn add_announce( - &self, - activity_id: &str, - object_url: &str, - actor_url: &str, - announced_at: chrono::DateTime, - ) -> 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; -} +pub trait ActorRepository: KeypairRepository + RemoteActorCache + AnnounceRepository {} +impl ActorRepository for T {} diff --git a/src/repository/actor_blocklist.rs b/src/repository/actor_blocklist.rs new file mode 100644 index 0000000..b5254af --- /dev/null +++ b/src/repository/actor_blocklist.rs @@ -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>; + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result; +} diff --git a/src/repository/announce.rs b/src/repository/announce.rs new file mode 100644 index 0000000..8fd96c6 --- /dev/null +++ b/src/repository/announce.rs @@ -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, + ) -> 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; +} diff --git a/src/repository/blocklist.rs b/src/repository/blocklist.rs index fa637b9..b255c20 100644 --- a/src/repository/blocklist.rs +++ b/src/repository/blocklist.rs @@ -1,20 +1,5 @@ -use anyhow::Result; -use async_trait::async_trait; - -use super::BlockedDomain; +use super::{ActorBlocklist, DomainBlocklist}; /// Domain and actor-level blocklists. -#[async_trait] -pub trait BlocklistRepository: Send + Sync { - // ── 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>; - async fn is_domain_blocked(&self, domain: &str) -> Result; - - // ── 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>; - async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result; -} +pub trait BlocklistRepository: DomainBlocklist + ActorBlocklist {} +impl BlocklistRepository for T {} diff --git a/src/repository/domain_blocklist.rs b/src/repository/domain_blocklist.rs new file mode 100644 index 0000000..4e7a641 --- /dev/null +++ b/src/repository/domain_blocklist.rs @@ -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>; + async fn is_domain_blocked(&self, domain: &str) -> Result; +} diff --git a/src/repository/follow.rs b/src/repository/follow.rs index 303c184..efd25e9 100644 --- a/src/repository/follow.rs +++ b/src/repository/follow.rs @@ -1,98 +1,11 @@ -use anyhow::Result; -use async_trait::async_trait; - -use super::{Follower, FollowerStatus, FollowingStatus, RemoteActor}; +use super::{FollowMigration, FollowerReader, FollowerWriter, FollowingReader, FollowingWriter}; /// Manages follower/following relationships and account migration. -#[async_trait] -pub trait FollowRepository: Send + Sync { - // ── Inbound followers ─────────────────────────────────────────────────── - 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>; - 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>; - async fn get_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result>; - async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result; - 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>; - /// 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>; - /// 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; - /// 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>; - - // ── 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>; - 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>; - async fn get_following_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result>; - async fn count_following(&self, local_user_id: uuid::Uuid) -> Result; - 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>; - - // ── 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>; +pub trait FollowRepository: + FollowerWriter + FollowerReader + FollowingWriter + FollowingReader + FollowMigration +{ +} +impl + FollowRepository for T +{ } diff --git a/src/repository/follow_migration.rs b/src/repository/follow_migration.rs new file mode 100644 index 0000000..44a2813 --- /dev/null +++ b/src/repository/follow_migration.rs @@ -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> { + Ok(vec![]) + } +} diff --git a/src/repository/follower_reader.rs b/src/repository/follower_reader.rs new file mode 100644 index 0000000..bdbe6a4 --- /dev/null +++ b/src/repository/follower_reader.rs @@ -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>; + async fn get_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result>; + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result; + async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result>; + /// 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>; + /// 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; + /// 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>; +} diff --git a/src/repository/follower_writer.rs b/src/repository/follower_writer.rs new file mode 100644 index 0000000..69cafdb --- /dev/null +++ b/src/repository/follower_writer.rs @@ -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>; + 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<()>; +} diff --git a/src/repository/following_reader.rs b/src/repository/following_reader.rs new file mode 100644 index 0000000..32ab9e2 --- /dev/null +++ b/src/repository/following_reader.rs @@ -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>; + async fn get_following_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result>; + async fn count_following(&self, local_user_id: uuid::Uuid) -> Result; +} diff --git a/src/repository/following_writer.rs b/src/repository/following_writer.rs new file mode 100644 index 0000000..48e8b1d --- /dev/null +++ b/src/repository/following_writer.rs @@ -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>; + 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<()>; +} diff --git a/src/repository/keypair.rs b/src/repository/keypair.rs new file mode 100644 index 0000000..8d0a370 --- /dev/null +++ b/src/repository/keypair.rs @@ -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>; + async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()>; +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs index f94389e..b7efdf8 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -1,57 +1,31 @@ mod activity; mod actor; +mod actor_blocklist; +mod announce; mod blocklist; +mod domain_blocklist; 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 actor::ActorRepository; +pub use actor_blocklist::ActorBlocklist; +pub use announce::AnnounceRepository; pub use blocklist::BlocklistRepository; +pub use domain_blocklist::DomainBlocklist; pub use follow::FollowRepository; - -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, - pub display_name: Option, - pub avatar_url: Option, - pub outbox_url: Option, - pub bio: Option, - pub banner_url: Option, - pub followers_url: Option, - pub following_url: Option, - pub also_known_as: Vec, - /// 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>, -} - -#[derive(Debug, Clone)] -pub struct Follower { - pub actor: RemoteActor, - pub status: FollowerStatus, -} - -#[derive(Debug, Clone)] -pub struct BlockedDomain { - pub domain: String, - pub reason: Option, - pub blocked_at: String, -} +pub use follow_migration::FollowMigration; +pub use follower_reader::FollowerReader; +pub use follower_writer::FollowerWriter; +pub use following_reader::FollowingReader; +pub use following_writer::FollowingWriter; +pub use keypair::KeypairRepository; +pub use remote_actor_cache::RemoteActorCache; +pub use types::{BlockedDomain, Follower, FollowerStatus, FollowingStatus, Keypair, RemoteActor}; diff --git a/src/repository/remote_actor_cache.rs b/src/repository/remote_actor_cache.rs new file mode 100644 index 0000000..81a476b --- /dev/null +++ b/src/repository/remote_actor_cache.rs @@ -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>; +} diff --git a/src/repository/types.rs b/src/repository/types.rs new file mode 100644 index 0000000..7e1cbd7 --- /dev/null +++ b/src/repository/types.rs @@ -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, + pub display_name: Option, + pub avatar_url: Option, + pub outbox_url: Option, + pub bio: Option, + pub banner_url: Option, + pub followers_url: Option, + pub following_url: Option, + pub also_known_as: Vec, + /// 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>, +} + +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, + pub blocked_at: String, +} + +#[derive(Debug, Clone)] +pub struct Keypair { + pub public_key: String, + pub private_key: String, +} diff --git a/src/security.rs b/src/security.rs deleted file mode 100644 index 017d01a..0000000 --- a/src/security.rs +++ /dev/null @@ -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())); - } -} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..3b30591 --- /dev/null +++ b/src/security/mod.rs @@ -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; diff --git a/src/security/tests.rs b/src/security/tests.rs new file mode 100644 index 0000000..d04adcb --- /dev/null +++ b/src/security/tests.rs @@ -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())); +} diff --git a/src/service/backfill.rs b/src/service/backfill.rs index a73caf8..1f287e0 100644 --- a/src/service/backfill.rs +++ b/src/service/backfill.rs @@ -1,6 +1,4 @@ -use activitypub_federation::{ - activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext, -}; +use activitypub_federation::{activity_sending::SendActivityTask, protocol::context::WithContext}; use url::Url; use crate::{activities::CreateActivity, actors::get_local_actor, federation::ApFederationConfig}; @@ -30,9 +28,10 @@ impl ActivityPubService { .build()?; let data = self.federation_config.to_request_data(); let actor = url::Url::parse(actor_url)?; + let root: serde_json::Value = client .get(outbox_url) - .header("Accept", "application/activity+json") + .header("Accept", crate::urls::AP_CONTENT_TYPE) .send() .await? .json() @@ -44,6 +43,7 @@ impl ActivityPubService { return Ok(()); } }; + let mut current_url = first; let mut visited = std::collections::HashSet::new(); loop { @@ -57,9 +57,10 @@ impl ActivityPubService { tracing::warn!(url = %current_url, error = %e, "backfill: SSRF check failed"); break; } + let page: serde_json::Value = match client .get(¤t_url) - .header("Accept", "application/activity+json") + .header("Accept", crate::urls::AP_CONTENT_TYPE) .send() .await { @@ -75,6 +76,7 @@ impl ActivityPubService { break; } }; + if let Some(items) = page.get("orderedItems").and_then(|v| v.as_array()) { for item in items { 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()) { Some(next) => current_url = next.to_string(), None => break, } } + tracing::info!(outbox = %outbox_url, pages = visited.len(), "backfill complete"); Ok(()) } @@ -150,7 +154,7 @@ impl ActivityPubService { } /// 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`, /// oldest-to-newest, with a small sleep between batches to avoid overwhelming @@ -181,13 +185,9 @@ impl ActivityPubService { ) -> anyhow::Result<()> { const BATCH_SIZE: usize = 20; let data = config.to_request_data(); - let local_actor = get_local_actor(owner_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(owner_user_id, &data).await?; 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> = None; 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; - // Advance cursor to the oldest timestamp in this page. - before = page.last().map(|(_, _, ts)| *ts); + before = page.last().map(|item| item.published_at); - for (ap_id, object_json, _ts) in &page { + for item in &page { let create_id = Url::parse(&format!( "{}/activities/create/{}", 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 { id: create_id, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: object_json.clone(), - to: vec![], - cc: vec![], + actor: local_actor.object_id(), + object: item.object.clone(), + to: item.to.clone(), + cc: item.cc.clone(), bto: vec![], bcc: vec![], }; + let sends = SendActivityTask::prepare( &WithContext::new_default(create), &local_actor, @@ -228,6 +228,7 @@ impl ActivityPubService { &data, ) .await?; + total += 1; if send_with_retry(sends, &data, max_attempts, initial_delay) .await diff --git a/src/service/broadcast.rs b/src/service/broadcast.rs index 34ae190..602725f 100644 --- a/src/service/broadcast.rs +++ b/src/service/broadcast.rs @@ -1,6 +1,4 @@ -use activitypub_federation::{ - fetch::object_id::ObjectId, protocol::context::WithContext, traits::Object, -}; +use activitypub_federation::{protocol::context::WithContext, traits::Object}; use url::Url; use crate::{ @@ -8,144 +6,138 @@ use crate::{ AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity, UpdateActivity, }, - actors::get_local_actor, - urls::activity_url, + actors::{DbActor, get_local_actor}, + data::FederationData, user::ApVisibility, }; 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 { + 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 { pub async fn broadcast_announce_to_followers( &self, local_user_id: uuid::Uuid, - object_ap_id: url::Url, + object_ap_id: Url, ) -> anyhow::Result<()> { - let announce_id = url::Url::parse(&format!( - "{}/activities/announce/{}", - 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 announce_id = + deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?; let data = self.federation_config.to_request_data(); let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { return Ok(()); }; + let announce = AnnounceActivity { id: announce_id, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: object_ap_id, published: Some(chrono::Utc::now()), to: vec![crate::urls::AS_PUBLIC.to_string()], cc: vec![local_actor.followers_url.to_string()], }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, inboxes, announce) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, inboxes, announce) .await } pub async fn broadcast_undo_announce_to_followers( &self, local_user_id: uuid::Uuid, - object_ap_id: url::Url, + object_ap_id: Url, ) -> anyhow::Result<()> { - let announce_id = url::Url::parse(&format!( - "{}/activities/announce/{}", - 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 announce_id = + deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?; let data = self.federation_config.to_request_data(); let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { return Ok(()); }; + let undo = UndoActivity { - id: undo_id, + id: data.url_scheme.activity_url(&self.base_url)?, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: serde_json::json!({"type":"Announce","id":announce_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}), + actor: local_actor.object_id(), + 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) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) - .await + + self.send_activity(&data, &local_actor, inboxes, undo).await } pub async fn broadcast_like_to_inbox( &self, liker_user_id: uuid::Uuid, - object_ap_id: url::Url, - author_inbox_url: url::Url, + object_ap_id: Url, + author_inbox_url: Url, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(liker_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - 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 local_actor = get_local_actor(liker_user_id, &data).await?; + let like_id = + deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?; + let like = crate::activities::LikeActivity { id: like_id, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: object_ap_id, }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, vec![author_inbox_url], like) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, vec![author_inbox_url], like) .await } pub async fn broadcast_undo_like_to_inbox( &self, liker_user_id: uuid::Uuid, - object_ap_id: url::Url, - author_inbox_url: url::Url, + object_ap_id: Url, + author_inbox_url: Url, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(liker_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - 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 local_actor = get_local_actor(liker_user_id, &data).await?; + let like_id = + deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?; + let undo = UndoActivity { - id: undo_id, + id: data.url_scheme.activity_url(&self.base_url)?, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: serde_json::json!({"type":"Like","id":like_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}), + actor: local_actor.object_id(), + 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) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, vec![author_inbox_url], undo) .await } @@ -160,18 +152,20 @@ impl ActivityPubService { else { return Ok(()); }; + 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: serde_json::json!({"type": "Tombstone", "id": ap_id.to_string()}), + actor: local_actor.object_id(), + object: serde_json::to_value(TombstoneRef { + kind: "Tombstone", + id: ap_id.to_string(), + })?, to: vec![crate::urls::AS_PUBLIC.to_string()], cc: vec![local_actor.followers_url.to_string()], }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, inboxes, delete) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, inboxes, delete) .await } @@ -187,25 +181,23 @@ impl ActivityPubService { else { return Ok(()); }; + let add = AddActivity { id: ap_id, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object, to: vec![crate::urls::AS_PUBLIC.to_string()], cc: vec![local_actor.followers_url.to_string()], }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, inboxes, add) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) - .await + + self.send_activity(&data, &local_actor, inboxes, add).await } pub async fn broadcast_undo_add_to_followers( &self, local_user_id: uuid::Uuid, - watchlist_entry_ap_id: Url, + object_ap_id: Url, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); let Some((local_actor, inboxes)) = @@ -213,153 +205,136 @@ impl ActivityPubService { else { return Ok(()); }; + 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: serde_json::json!({"type":"Add","id":watchlist_entry_ap_id.as_str(),"object":{"id":watchlist_entry_ap_id.as_str()}}), + actor: local_actor.object_id(), + 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) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) - .await + + self.send_activity(&data, &local_actor, inboxes, undo).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, + ) -> anyhow::Result< + Option<( + activitypub_federation::config::Data, + DbActor, + Vec, + 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. /// - /// `visibility` controls `to`/`cc` addressing and whether the note is public: + /// `visibility` controls `to`/`cc` addressing: /// - `Public` / `FollowersOnly`: delivered to followers + `mentioned_inboxes` /// - `Private`: returns immediately — no delivery to anyone /// /// `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 /// empty `Vec` if there are no external mentions. - pub async fn broadcast_create_note( + pub async fn broadcast_create( &self, local_user_id: uuid::Uuid, - note: serde_json::Value, + object: serde_json::Value, visibility: ApVisibility, mentioned_inboxes: Vec, ) -> 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(()); - } - 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, - // 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 = 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 object_id_str = object["id"].as_str().unwrap_or(""); let create_id = Url::parse(&format!( "{}/activities/create/{}", self.base_url, - uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, note_id_str.as_bytes()) - )) - .map_err(|e| anyhow::anyhow!("{e}"))?; - let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url); + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, object_id_str.as_bytes()) + ))?; let create = CreateActivity { id: create_id, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: note, - to, - cc, + actor: local_actor.object_id(), + object, + to: addressing.to, + cc: addressing.cc, bto: vec![], bcc: vec![], }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, inboxes, create) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, inboxes, create) .await } - /// Fan out an Update(Note) activity to accepted followers and mentioned actors. - /// See [`broadcast_create_note`] for `mentioned_inboxes` semantics. - pub async fn broadcast_update_note( + /// Fan out an Update activity to accepted followers and mentioned actors. + /// See [`ActivityPubService::broadcast_create`] for `mentioned_inboxes` semantics. + pub async fn broadcast_update( &self, local_user_id: uuid::Uuid, - note: serde_json::Value, + object: serde_json::Value, visibility: ApVisibility, mentioned_inboxes: Vec, ) -> 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(()); - } - 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 = 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) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + let update = UpdateActivity { + id: data.url_scheme.activity_url(&self.base_url)?, + kind: Default::default(), + actor: local_actor.object_id(), + object, + to: addressing.to, + cc: addressing.cc, + }; + + self.send_activity(&data, &local_actor, inboxes, update) .await } pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - let person = local_actor - .clone() - .into_json(&data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(user_id, &data).await?; + + let person = local_actor.clone().into_json(&data).await?; let person_json = serde_json::to_value(WithContext::new(person, crate::urls::actor_ap_context()))?; let update_id = Url::parse(&format!( @@ -370,65 +345,81 @@ impl ActivityPubService { let update = UpdateActivity { id: update_id, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: person_json, to: vec![crate::urls::AS_PUBLIC.to_string()], cc: vec![local_actor.followers_url.to_string()], }; + let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else { tracing::info!(%user_id, "no accepted followers, skipping actor update broadcast"); return Ok(()); }; + tracing::info!(%user_id, inbox_count = inboxes.len(), "broadcasting actor update"); - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, inboxes, update) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + self.send_activity(&data, &local_actor, inboxes, update) .await } pub async fn broadcast_move( &self, user_id: uuid::Uuid, - new_actor_url: url::Url, + new_actor_url: Url, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(user_id, &data).await?; let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else { tracing::info!(%user_id, "broadcast_move: no accepted followers"); return Ok(()); }; + 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: local_actor.ap_id.clone(), target: new_actor_url.clone(), }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, inboxes, move_activity) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, inboxes, move_activity) .await?; tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched"); 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. -/// `Private` is handled before calling this (early return in broadcast methods). -pub(crate) fn visibility_addressing( - visibility: ApVisibility, - followers_url: &Url, -) -> (Vec, Vec) { - 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![]), + self.send_raw_activity(&data, &local_actor, inboxes, activity) + .await } } + +fn merge_inboxes(follower_inboxes: Vec, mentioned_inboxes: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut inboxes: Vec = 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 +} diff --git a/src/service/builder.rs b/src/service/builder.rs new file mode 100644 index 0000000..f3bb997 --- /dev/null +++ b/src/service/builder.rs @@ -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>, + follow_repo: Option>, + actor_repo: Option>, + blocklist_repo: Option>, + user_repo: Option>, + content_reader: Option>, + object_handler: Option>, + base_url: String, + allow_registration: bool, + software_name: String, + debug: bool, + event_publisher: Option>, + delivery_max_attempts: u32, + delivery_initial_delay_secs: u64, + signed_fetch_actor_id: Option, + actor_cache_ttl_secs: u64, + url_scheme: Option>, + nodeinfo_services_inbound: Vec, + nodeinfo_services_outbound: Vec, + 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) -> Self { + self.activity_repo = Some(activity_repo); + self + } + pub fn follow_repo(mut self, follow_repo: Arc) -> Self { + self.follow_repo = Some(follow_repo); + self + } + pub fn actor_repo(mut self, actor_repo: Arc) -> Self { + self.actor_repo = Some(actor_repo); + self + } + pub fn blocklist_repo(mut self, blocklist_repo: Arc) -> Self { + self.blocklist_repo = Some(blocklist_repo); + self + } + pub fn user_repo(mut self, user_repo: Arc) -> Self { + self.user_repo = Some(user_repo); + self + } + pub fn content_reader(mut self, content_reader: Arc) -> Self { + self.content_reader = Some(content_reader); + self + } + pub fn object_handler(mut self, object_handler: Arc) -> 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) -> 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, + ) -> 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, outbound: Vec) -> 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) -> 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 { + 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, + }) + } +} diff --git a/src/service/collections.rs b/src/service/collections.rs new file mode 100644 index 0000000..29e002c --- /dev/null +++ b/src/service/collections.rs @@ -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 { + 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, + ) -> anyhow::Result { + 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, + ) -> anyhow::Result { + 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( + collection_url: &str, + total: usize, + page: Option, + fetch_items: F, +) -> anyhow::Result +where + F: FnOnce(u32, usize) -> Fut, + Fut: std::future::Future>>, +{ + 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)?) +} diff --git a/src/service/delivery.rs b/src/service/delivery.rs index fcd9dc7..9a88456 100644 --- a/src/service/delivery.rs +++ b/src/service/delivery.rs @@ -84,28 +84,32 @@ impl Activity for RawActivity { } impl ActivityPubService { - /// Route deliveries to the EventPublisher (one DeliveryRequested event per inbox) - /// or fall back to a fire-and-forget tokio::spawn. - /// `pub(crate)` so sibling modules (broadcast.rs, follow.rs) can call it on `self`. - pub(crate) async fn dispatch_deliveries( + /// Dispatch pre-built `SendActivityTask`s via the event publisher (if configured) + /// or by spawning a background retry loop. + fn dispatch_sends( &self, data: &activitypub_federation::config::Data, local_actor: &DbActor, inboxes: Vec, sends: Vec, activity_json: serde_json::Value, - ) -> anyhow::Result<()> { + ) { if let Some(publisher) = data.event_publisher.as_ref() { - for inbox in inboxes { - let event = FederationEvent::DeliveryRequested { - inbox, - activity: activity_json.clone(), - signing_actor_id: local_actor.user_id, - }; - if let Err(e) = publisher.publish(event).await { - tracing::warn!(error = %e, "failed to enqueue DeliveryRequested event"); + let publisher = publisher.clone(); + let signing_actor_id = local_actor.user_id; + let activity = activity_json; + tokio::spawn(async move { + for inbox in inboxes { + let event = FederationEvent::DeliveryRequested { + inbox, + activity: activity.clone(), + signing_actor_id, + }; + if let Err(error) = publisher.publish(event).await { + tracing::warn!(%error, "failed to enqueue DeliveryRequested event"); + } } - } + }); } else { let data = data.clone(); let max_attempts = self.delivery_max_attempts; @@ -117,7 +121,6 @@ impl ActivityPubService { } }); } - Ok(()) } /// Deliver a single outbound activity to `inbox`. @@ -129,9 +132,8 @@ impl ActivityPubService { signing_actor_id: uuid::Uuid, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let actor = get_local_actor(signing_actor_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let actor = get_local_actor(signing_actor_id, &data).await?; + let id = activity .get("id") .and_then(|v| v.as_str()) @@ -147,6 +149,7 @@ impl ActivityPubService { actor_url, value: activity.clone(), }; + let sends = SendActivityTask::prepare(&raw, &actor, vec![inbox.clone()], &data).await?; let failures = send_with_retry( sends, @@ -158,34 +161,35 @@ impl ActivityPubService { if failures.is_empty() { return Ok(()); } + let error_msg = failures .iter() .map(|e| e.to_string()) .collect::>() .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 { inbox, activity, signing_actor_id, error: error_msg.clone(), }) - .await; + .await + { + tracing::warn!(%error, "failed to publish DeliveryFailed event"); } Err(anyhow::anyhow!("delivery failed: {}", error_msg)) } - /// Serialize `activity` to JSON and prepare `SendActivityTask` objects. - /// 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( + pub(super) async fn send_activity( &self, data: &activitypub_federation::config::Data, local_actor: &DbActor, inboxes: Vec, activity: A, - ) -> anyhow::Result<(serde_json::Value, Vec, Vec)> + ) -> anyhow::Result<()> where A: Activity + Serialize + Debug + Send + Sync, { @@ -194,6 +198,35 @@ impl ActivityPubService { let activity_json = serde_json::to_value(&with_ctx)?; let sends = 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, + local_actor: &DbActor, + inboxes: Vec, + 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(()) } } diff --git a/src/service/fetch.rs b/src/service/fetch.rs index 174c308..e2acfcc 100644 --- a/src/service/fetch.rs +++ b/src/service/fetch.rs @@ -17,8 +17,7 @@ impl ActivityPubService { crate::data::FederationData, serde_json::Value, >(url, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + .await?; Ok(res.object) } diff --git a/src/service/follow.rs b/src/service/follow.rs index d8d8933..ecb1ebc 100644 --- a/src/service/follow.rs +++ b/src/service/follow.rs @@ -6,7 +6,6 @@ use crate::{ actors::get_local_actor, data::FederationData, repository::{FollowerStatus, FollowingStatus, RemoteActor}, - urls::activity_url, }; use super::ActivityPubService; @@ -19,51 +18,26 @@ impl ActivityPubService { if parts.len() == 2 && parts[1] == data.domain { return self.follow_local(local_user_id, parts[0], &data).await; } + let remote_actor = self.webfinger_https(handle, &data).await?; - let local_actor = get_local_actor(local_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(local_user_id, &data).await?; + let follow_id = data.url_scheme.activity_url(&self.base_url)?; let follow_id_str = follow_id.to_string(); - let remote = RemoteActor { - 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()), - }; + let remote = RemoteActor::from(&remote_actor); + // Save BEFORE delivering — prevents lost state on process restart. data.follow_repo .add_following(local_user_id, remote, &follow_id_str) .await?; + let follow = FollowActivity { id: Url::parse(&follow_id_str)?, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: ObjectId::from(remote_actor.ap_id.clone()), }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, vec![remote_actor.inbox()], follow) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, vec![remote_actor.inbox()], follow) .await } @@ -78,14 +52,13 @@ impl ActivityPubService { .unfollow_local(local_user_id, actor_url_str, &data) .await; } + let remote = data .actor_repo .get_remote_actor(actor_url_str) .await? .ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?; - let local_actor = get_local_actor(local_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(local_user_id, &data).await?; let remote_ap_id = Url::parse(actor_url_str)?; let inbox = Url::parse(&remote.inbox_url)?; let follow_id = data @@ -94,25 +67,27 @@ impl ActivityPubService { .await? .and_then(|id| Url::parse(&id).ok()) .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 { id: follow_id, kind: Default::default(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: ObjectId::from(remote_ap_id), }; 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?, + actor: local_actor.object_id(), + object: serde_json::to_value(&follow)?, }; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, vec![inbox], undo) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + + self.send_activity(&data, &local_actor, vec![inbox], undo) .await?; + data.follow_repo .remove_following(local_user_id, actor_url_str) .await?; @@ -128,9 +103,7 @@ impl ActivityPubService { remote_actor_url: &str, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(local_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(local_user_id, &data).await?; let remote_actor = data .actor_repo .get_remote_actor(remote_actor_url) @@ -143,27 +116,27 @@ impl ActivityPubService { .ok_or_else(|| { anyhow::anyhow!("follow activity id not found for {}", remote_actor_url) })?; + let follow = FollowActivity { id: Url::parse(&follow_id_str)?, kind: Default::default(), actor: ObjectId::from(Url::parse(remote_actor_url)?), - object: ObjectId::from(local_actor.ap_id.clone()), + object: local_actor.object_id(), }; 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: follow, }; + data.follow_repo .update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted) .await?; let inbox = Url::parse(&remote_actor.inbox_url)?; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, vec![inbox], accept) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + self.send_activity(&data, &local_actor, vec![inbox], accept) .await?; + let target_inbox = remote_actor .shared_inbox_url .clone() @@ -178,32 +151,30 @@ impl ActivityPubService { remote_actor_url: &str, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let local_actor = get_local_actor(local_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(local_user_id, &data).await?; let remote_actor = data .actor_repo .get_remote_actor(remote_actor_url) .await? .ok_or_else(|| anyhow::anyhow!("remote actor not found"))?; + 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(), actor: ObjectId::from(Url::parse(remote_actor_url)?), - object: ObjectId::from(local_actor.ap_id.clone()), + object: local_actor.object_id(), }; 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: follow, }; + let inbox = Url::parse(&remote_actor.inbox_url)?; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, vec![inbox], reject) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + self.send_activity(&data, &local_actor, vec![inbox], reject) .await?; + data.follow_repo .remove_follower(local_user_id, remote_actor_url) .await?; @@ -243,8 +214,8 @@ impl ActivityPubService { .get_followers(local_user_id) .await? .into_iter() - .filter(|f| f.status == FollowerStatus::Accepted) - .map(|f| f.actor) + .filter(|follower| follower.status == FollowerStatus::Accepted) + .map(|follower| follower.actor) .collect()) } @@ -292,29 +263,32 @@ impl ActivityPubService { data.blocklist_repo .add_blocked_actor(local_user_id, actor_url) .await?; - let _ = data + if let Err(error) = data .follow_repo .remove_follower(local_user_id, actor_url) - .await; - let _ = data + .await + { + tracing::debug!(%error, "follower already removed"); + } + if let Err(error) = data .follow_repo .remove_following(local_user_id, actor_url) - .await; - let local_actor = get_local_actor(local_user_id, &data) .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 { 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: Url::parse(actor_url)?, }; + let inbox = Url::parse(&remote_actor.inbox_url)?; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, vec![inbox], block) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + self.send_activity(&data, &local_actor, vec![inbox], block) .await?; } Ok(()) @@ -329,27 +303,24 @@ impl ActivityPubService { data.blocklist_repo .remove_blocked_actor(local_user_id, actor_url) .await?; - let local_actor = get_local_actor(local_user_id, &data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + + 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 { 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), + actor: local_actor.object_id(), object: Url::parse(actor_url)?, }; 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(), - actor: ObjectId::from(local_actor.ap_id.clone()), - object: serde_json::to_value(&block).map_err(|e| anyhow::anyhow!("{e}"))?, + actor: local_actor.object_id(), + object: serde_json::to_value(&block)?, }; + let inbox = Url::parse(&remote_actor.inbox_url)?; - let (json, sends, inboxes) = self - .prepare_broadcast(&data, &local_actor, vec![inbox], undo) - .await?; - self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json) + self.send_activity(&data, &local_actor, vec![inbox], undo) .await?; tracing::info!(actor = %actor_url, "sent Undo(Block)"); } @@ -368,22 +339,8 @@ impl ActivityPubService { let mut actors = Vec::new(); for url in actor_urls { let actor = match data.actor_repo.get_remote_actor(&url).await { - Ok(Some(a)) => a, - _ => RemoteActor { - 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, - }, + Ok(Some(cached)) => cached, + _ => RemoteActor::placeholder(url), }; actors.push(actor); } @@ -404,11 +361,14 @@ impl ActivityPubService { if target.id == local_user_id { 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 follow_id = activity_url(&self.base_url) - .map_err(|e| anyhow::anyhow!("{e}"))? + + let follower_actor_url = data + .url_scheme + .actor_url(&self.base_url, local_user_id)? .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 .add_follower( target.id, @@ -417,21 +377,31 @@ impl ActivityPubService { &follow_id, ) .await?; + let target_as_remote = RemoteActor { url: target_actor_url.to_string(), 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, display_name: target.display_name.or(Some(target.username)), - avatar_url: target.avatar_url.as_ref().map(|u| u.to_string()), - outbox_url: Some(format!("{}/outbox", target_actor_url)), + avatar_url: target.avatar_url.as_ref().map(|url| url.to_string()), + outbox_url: Some(data.url_scheme.outbox_url(&target_actor_url)?.to_string()), bio: target.bio, - banner_url: target.banner_url.as_ref().map(|u| u.to_string()), - followers_url: Some(format!("{}/followers", target_actor_url)), - following_url: Some(format!("{}/following", target_actor_url)), + banner_url: target.banner_url.as_ref().map(|url| url.to_string()), + followers_url: Some( + 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, fetched_at: None, }; + data.follow_repo .add_following(local_user_id, target_as_remote, &follow_id) .await?; @@ -442,6 +412,7 @@ impl ActivityPubService { FollowingStatus::Accepted, ) .await?; + tracing::info!(follower = %local_user_id, followee = %target.id, "local follow"); Ok(()) } @@ -453,15 +424,22 @@ impl ActivityPubService { data: &activitypub_federation::config::Data, ) -> anyhow::Result<()> { 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))?; - 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 .remove_follower(target_user_id, &local_actor_url) .await?; data.follow_repo .remove_following(local_user_id, target_actor_url) .await?; + tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow"); Ok(()) } diff --git a/src/service/lookup.rs b/src/service/lookup.rs new file mode 100644 index 0000000..4910dc6 --- /dev/null +++ b/src/service/lookup.rs @@ -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> { + 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 { + 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 { + 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> { + 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 { + 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, + ) -> anyhow::Result { + 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) + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs index df7b1a8..6c1556c 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -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 axum::{Router, extract::DefaultBodyLimit, routing::get, routing::post}; + use crate::{ actors::{DbActor, get_local_actor}, - content::{ApContentReader, ApObjectHandler}, data::FederationData, - featured_handler::featured_handler, federation::ApFederationConfig, - inbox::inbox_handler, - nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, - outbox::outbox_handler, - repository::{ - ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, + handlers::{ + featured::featured_handler, + inbox::inbox_handler, + nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, + outbox::outbox_handler, + webfinger::webfinger_handler, }, - user::ApUserRepository, - webfinger::webfinger_handler, }; mod backfill; pub(crate) mod broadcast; +mod builder; +pub(crate) mod collections; pub(super) mod delivery; mod fetch; mod follow; +mod lookup; +pub(crate) mod types; + +pub use builder::ActivityPubServiceBuilder; /// Default max delivery retries per inbox (used as the builder default). pub const DELIVERY_MAX_ATTEMPTS: u32 = 3; @@ -45,195 +46,9 @@ pub struct ActivityPubService { pub(super) delivery_initial_delay_secs: u64, } -pub struct ActivityPubServiceBuilder { - activity_repo: Option>, - follow_repo: Option>, - actor_repo: Option>, - blocklist_repo: Option>, - user_repo: Option>, - content_reader: Option>, - object_handler: Option>, - base_url: String, - allow_registration: bool, - software_name: String, - debug: bool, - event_publisher: Option>, - delivery_max_attempts: u32, - delivery_initial_delay_secs: u64, - signed_fetch_actor_id: Option, - actor_cache_ttl_secs: u64, - nodeinfo_services_inbound: Vec, - nodeinfo_services_outbound: Vec, - nodeinfo_metadata: serde_json::Value, -} - -impl ActivityPubServiceBuilder { - pub fn activity_repo(mut self, v: Arc) -> Self { - self.activity_repo = Some(v); - self - } - pub fn follow_repo(mut self, v: Arc) -> Self { - self.follow_repo = Some(v); - self - } - pub fn actor_repo(mut self, v: Arc) -> Self { - self.actor_repo = Some(v); - self - } - pub fn blocklist_repo(mut self, v: Arc) -> Self { - self.blocklist_repo = Some(v); - self - } - pub fn user_repo(mut self, v: Arc) -> Self { - self.user_repo = Some(v); - self - } - pub fn content_reader(mut self, v: Arc) -> Self { - self.content_reader = Some(v); - self - } - pub fn object_handler(mut self, v: Arc) -> 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) -> 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) -> 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, outbound: Vec) -> 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 { - 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 { pub fn builder(base_url: impl Into) -> ActivityPubServiceBuilder { - ActivityPubServiceBuilder { - 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!({}), - } + ActivityPubServiceBuilder::new(base_url.into()) } pub fn federation_config(&self) -> &ApFederationConfig { @@ -269,178 +84,23 @@ impl ActivityPubService { .route("/.well-known/webfinger", get(webfinger_handler)) .route( "/inbox", - post(inbox_handler).layer(DefaultBodyLimit::max(1024 * 1024)), + post(inbox_handler).layer(DefaultBodyLimit::max(crate::urls::INBOX_BODY_LIMIT)), ) .route( "/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}/featured", get(featured_handler)) .layer(self.federation_config.middleware()) } - pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result { - 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, - ) -> anyhow::Result { - 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 = 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, - ) -> anyhow::Result { - 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 = 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 { - 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> { - 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( &self, data: &activitypub_federation::config::Data, local_user_id: uuid::Uuid, ) -> anyhow::Result)>> { - let local_actor = get_local_actor(local_user_id, data) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + let local_actor = get_local_actor(local_user_id, data).await?; let inbox_strs = data .follow_repo .get_accepted_follower_inboxes(local_user_id) @@ -448,62 +108,12 @@ impl ActivityPubService { if inbox_strs.is_empty() { return Ok(None); } - let inboxes: Vec = inbox_strs.into_iter().filter_map(|s| { - Url::parse(&s).map_err(|e| tracing::warn!(inbox = %s, error = %e, "skipping unparseable inbox URL")).ok() + let inboxes: Vec = inbox_strs.into_iter().filter_map(|inbox_str| { + Url::parse(&inbox_str).map_err(|e| tracing::warn!(inbox = %inbox_str, error = %e, "skipping unparseable inbox URL")).ok() }).collect(); if inboxes.is_empty() { return Ok(None); } Ok(Some((local_actor, inboxes))) } - - async fn webfinger_https( - &self, - handle: &str, - data: &activitypub_federation::config::Data, - ) -> anyhow::Result { - 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. } diff --git a/src/service/types.rs b/src/service/types.rs new file mode 100644 index 0000000..e58986b --- /dev/null +++ b/src/service/types.rs @@ -0,0 +1,63 @@ +use url::Url; + +use crate::user::ApVisibility; + +pub(crate) struct Addressing { + pub to: Vec, + pub cc: Vec, +} + +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, +} diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 0000000..69172de --- /dev/null +++ b/src/testing.rs @@ -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 anyhow::Result>` 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 anyhow::Result<$ret> + Send + Sync>, + )*)* + } + + pub struct $builder { + $($( + $method: Option anyhow::Result<$ret> + Send + Sync>>, + )*)* + } + + impl $builder { + pub fn new() -> Self { + Self { + $($( + $method: None, + )*)* + } + } + + paste::paste! { + $($( + pub fn []( + 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; + 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; + fn get_followers_page( + local_user_id: uuid::Uuid, + offset: u32, + limit: usize + ) -> Vec; + fn count_followers(local_user_id: uuid::Uuid) -> usize; + fn get_pending_followers(local_user_id: uuid::Uuid) -> Vec; + fn get_accepted_follower_inboxes(local_user_id: uuid::Uuid) -> Vec; + 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; + } + 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; + 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; + fn get_following_page( + local_user_id: uuid::Uuid, + offset: u32, + limit: usize + ) -> Vec; + fn count_following(local_user_id: uuid::Uuid) -> usize; + } + trait FollowMigration { + fn migrate_follower_actor( + old_actor_url: &str, + new_actor_url: &str + ) -> Vec; + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MockActorRepo +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +mock_repo! { + MockActorRepo, MockActorRepoBuilder { + trait KeypairRepository { + fn get_local_actor_keypair(user_id: uuid::Uuid) -> Option; + 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; + } + trait AnnounceRepository { + fn add_announce( + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: DateTime + ) -> (); + 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; + 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; + 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; + fn find_by_username(username: &str) -> Option; + fn count_users() -> usize; + } + } +} + +mock_repo! { + MockContentReader, MockContentReaderBuilder { + trait ApContentReader { + fn get_local_objects_page( + user_id: uuid::Uuid, + before: Option>, + limit: usize + ) -> Vec; + fn count_local_posts() -> u64; + fn get_featured_objects(user_id: uuid::Uuid) -> Vec; + } + } +} + +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) -> (); + } + } +} diff --git a/src/tests/activities.rs b/src/tests/activities.rs index 6380b15..9784a26 100644 --- a/src/tests/activities.rs +++ b/src/tests/activities.rs @@ -1,470 +1,149 @@ /// Business-logic tests for activity receive() implementations. /// -/// These tests exercise each activity handler with in-memory stubs, +/// These tests exercise each activity handler with mock builders, /// verifying the correct callbacks fire and the correct repo mutations happen. -/// Activities that require outbound HTTP (Follow → dereference actor) are tested +/// Activities that require outbound HTTP (Follow -> dereference actor) are tested /// only for their early-return paths; the happy path requires a real HTTP stack. use std::collections::HashSet; use std::sync::Arc; use activitypub_federation::{config::FederationConfig, fetch::object_id::ObjectId}; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; use tokio::sync::Mutex; use url::Url; +use crate::activities::announce::AnnounceType; +use crate::activities::block::BlockType; +use crate::activities::like::LikeType; use crate::activities::{ - AcceptActivity, AddActivity, AnnounceActivity, AnnounceType, BlockActivity, BlockType, - CreateActivity, DeleteActivity, FollowActivity, LikeActivity, LikeType, RejectActivity, - UndoActivity, UpdateActivity, + AcceptActivity, AddActivity, AnnounceActivity, BlockActivity, CreateActivity, DeleteActivity, + FollowActivity, LikeActivity, RejectActivity, UndoActivity, UpdateActivity, }; -use crate::content::{ApContentReader, ApObjectHandler}; use crate::data::FederationData; -use crate::repository::{ - ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, - Follower, FollowerStatus, FollowingStatus, RemoteActor, +use crate::repository::FollowingStatus; +use crate::testing::{ + MockActivityRepoBuilder, MockActorRepoBuilder, MockBlocklistRepoBuilder, + MockContentReaderBuilder, MockFollowRepoBuilder, MockObjectHandlerBuilder, MockUserRepoBuilder, }; -use crate::user::{ApActorType, ApUser, ApUserRepository}; +use crate::user::{ApActorType, ApUser}; -// ── Stubs ───────────────────────────────────────────────────────────────────── +// ── Mock-builder-based tracking ───────────────────────────────────────────── +fn make_user(id: uuid::Uuid, username: &str) -> ApUser { + 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, + } +} + +/// Tracking vectors for object handler callbacks. #[derive(Default)] -struct MemActivityRepo { - processed: Mutex>, +struct HandlerTracking { + creates: Arc>>, + updates: Arc>>, + deletes: Arc>>, + actors_removed: Arc>>, + likes: Arc>>, + unlikes: Arc>>, + announces_received: Arc>>, + announces_removed: Arc>>, + announces_of_remote: Arc>>, + mentions: Arc>>, } -#[async_trait] -impl ActivityRepository for MemActivityRepo { - async fn is_activity_processed(&self, id: &str) -> anyhow::Result { - Ok(self.processed.lock().await.contains(id)) - } - async fn mark_activity_processed(&self, id: &str) -> anyhow::Result<()> { - self.processed.lock().await.insert(id.to_string()); - Ok(()) +impl HandlerTracking { + fn build_handler(&self) -> Arc { + let creates = self.creates.clone(); + let updates = self.updates.clone(); + let deletes = self.deletes.clone(); + let actors_removed = self.actors_removed.clone(); + let likes = self.likes.clone(); + let unlikes = self.unlikes.clone(); + let announces_received = self.announces_received.clone(); + let announces_removed = self.announces_removed.clone(); + let announces_of_remote = self.announces_of_remote.clone(); + let mentions = self.mentions.clone(); + + MockObjectHandlerBuilder::new() + .on_on_create(move |ap_id, actor_url, _| { + creates + .try_lock() + .unwrap() + .push((ap_id.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_update(move |ap_id, actor_url, _| { + updates + .try_lock() + .unwrap() + .push((ap_id.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_delete(move |ap_id, actor_url| { + deletes + .try_lock() + .unwrap() + .push((ap_id.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_actor_removed(move |actor_url| { + actors_removed.try_lock().unwrap().push(actor_url.clone()); + Ok(()) + }) + .on_on_like(move |object_url, actor_url| { + likes + .try_lock() + .unwrap() + .push((object_url.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_unlike(move |object_url, actor_url| { + unlikes + .try_lock() + .unwrap() + .push((object_url.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_announce_received(move |object_url, actor_url| { + announces_received + .try_lock() + .unwrap() + .push((object_url.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_announce_removed(move |object_url, actor_url| { + announces_removed + .try_lock() + .unwrap() + .push((object_url.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_announce_of_remote(move |object_url, actor_url| { + announces_of_remote + .try_lock() + .unwrap() + .push((object_url.clone(), actor_url.clone())); + Ok(()) + }) + .on_on_mention(move |ap_id, user_id, _| { + mentions.try_lock().unwrap().push((ap_id.clone(), user_id)); + Ok(()) + }) + .build() } } -/// Tracking follow repo — records every mutating call for assertion. -#[derive(Default)] -struct MemFollowRepo { - // recorded mutations - added_followers: Mutex>, - removed_followers: Mutex>, - removed_following: Mutex>, - following_status_updates: Mutex>, -} - -#[async_trait] -impl FollowRepository for MemFollowRepo { - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - _: &str, - ) -> anyhow::Result<()> { - self.added_followers.lock().await.push(( - local_user_id, - remote_actor_url.to_string(), - status, - )); - Ok(()) - } - async fn get_follower_follow_activity_id( - &self, - _: uuid::Uuid, - _: &str, - ) -> anyhow::Result> { - Ok(None) - } - async fn remove_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> anyhow::Result<()> { - self.removed_followers - .lock() - .await - .push((local_user_id, remote_actor_url.to_string())); - Ok(()) - } - async fn get_followers(&self, _: uuid::Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn get_followers_page( - &self, - _: uuid::Uuid, - _: u32, - _: usize, - ) -> anyhow::Result> { - Ok(vec![]) - } - async fn count_followers(&self, _: uuid::Uuid) -> anyhow::Result { - 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> { - Ok(vec![]) - } - async fn get_accepted_follower_inboxes(&self, _: uuid::Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> anyhow::Result { - Ok(0) - } - async fn get_accepted_followers_page( - &self, - _: uuid::Uuid, - _: u32, - _: usize, - ) -> anyhow::Result> { - 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> { - Ok(None) - } - async fn remove_following( - &self, - local_user_id: uuid::Uuid, - actor_url: &str, - ) -> anyhow::Result<()> { - self.removed_following - .lock() - .await - .push((local_user_id, actor_url.to_string())); - Ok(()) - } - async fn get_following(&self, _: uuid::Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn get_following_page( - &self, - _: uuid::Uuid, - _: u32, - _: usize, - ) -> anyhow::Result> { - Ok(vec![]) - } - async fn count_following(&self, _: uuid::Uuid) -> anyhow::Result { - Ok(0) - } - async fn update_following_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowingStatus, - ) -> anyhow::Result<()> { - self.following_status_updates.lock().await.push(( - local_user_id, - remote_actor_url.to_string(), - status, - )); - Ok(()) - } - async fn get_following_outbox_url( - &self, - _: uuid::Uuid, - _: &str, - ) -> anyhow::Result> { - Ok(None) - } - async fn migrate_follower_actor(&self, _: &str, _: &str) -> anyhow::Result> { - Ok(vec![]) - } -} - -/// Tracking actor repo. -#[derive(Default)] -struct MemActorRepo { - added_announces: Mutex>, - removed_announces: Mutex>, -} - -#[async_trait] -impl ActorRepository for MemActorRepo { - async fn get_local_actor_keypair( - &self, - _: uuid::Uuid, - ) -> anyhow::Result> { - 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> { - Ok(None) - } - async fn add_announce( - &self, - activity_id: &str, - _: &str, - _: &str, - _: DateTime, - ) -> anyhow::Result<()> { - self.added_announces - .lock() - .await - .push(activity_id.to_string()); - Ok(()) - } - async fn remove_announce(&self, activity_id: &str, _: &str) -> anyhow::Result<()> { - self.removed_announces - .lock() - .await - .push(activity_id.to_string()); - Ok(()) - } - async fn count_announces(&self, _: &str) -> anyhow::Result { - Ok(0) - } -} - -struct MemBlocklistRepo { - blocked_domains: HashSet, - blocked_actors: HashSet<(uuid::Uuid, String)>, -} - -impl MemBlocklistRepo { - fn blocking_domain(domain: &str) -> Self { - let mut blocked_domains = HashSet::new(); - blocked_domains.insert(domain.to_string()); - Self { - blocked_domains, - blocked_actors: HashSet::new(), - } - } - fn blocking_actor(local_user_id: uuid::Uuid, actor_url: &str) -> Self { - let mut blocked_actors = HashSet::new(); - blocked_actors.insert((local_user_id, actor_url.to_string())); - Self { - blocked_domains: HashSet::new(), - blocked_actors, - } - } -} - -impl Default for MemBlocklistRepo { - fn default() -> Self { - Self { - blocked_domains: HashSet::new(), - blocked_actors: HashSet::new(), - } - } -} - -#[async_trait] -impl BlocklistRepository for MemBlocklistRepo { - async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> { - Ok(()) - } - async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn get_blocked_domains(&self) -> anyhow::Result> { - Ok(vec![]) - } - async fn is_domain_blocked(&self, domain: &str) -> anyhow::Result { - Ok(self.blocked_domains.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> { - Ok(vec![]) - } - async fn is_actor_blocked( - &self, - local_user_id: uuid::Uuid, - actor_url: &str, - ) -> anyhow::Result { - Ok(self - .blocked_actors - .contains(&(local_user_id, actor_url.to_string()))) - } -} - -struct MemUserRepo { - user_id: uuid::Uuid, - username: String, -} - -impl MemUserRepo { - fn new(user_id: uuid::Uuid, username: &str) -> Self { - Self { - user_id, - username: username.to_string(), - } - } -} - -#[async_trait] -impl ApUserRepository for MemUserRepo { - async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result> { - if id == self.user_id { - Ok(Some(ApUser { - id, - username: self.username.clone(), - 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, - })) - } else { - Ok(None) - } - } - async fn find_by_username(&self, _: &str) -> anyhow::Result> { - Ok(None) - } - async fn count_users(&self) -> anyhow::Result { - Ok(1) - } -} - -/// Tracking object handler — records every callback for assertion. -#[derive(Default)] -struct MemHandler { - creates: Mutex>, // (ap_id, actor_url) - updates: Mutex>, - deletes: Mutex>, - actors_removed: Mutex>, - likes: Mutex>, - unlikes: Mutex>, - announces_received: Mutex>, - announces_removed: Mutex>, - announces_of_remote: Mutex>, - mentions: Mutex>, -} - -#[async_trait] -impl ApObjectHandler for MemHandler { - async fn on_create( - &self, - ap_id: &Url, - actor_url: &Url, - _: serde_json::Value, - ) -> anyhow::Result<()> { - self.creates - .lock() - .await - .push((ap_id.clone(), actor_url.clone())); - Ok(()) - } - async fn on_update( - &self, - ap_id: &Url, - actor_url: &Url, - _: serde_json::Value, - ) -> anyhow::Result<()> { - self.updates - .lock() - .await - .push((ap_id.clone(), actor_url.clone())); - Ok(()) - } - async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> { - self.deletes - .lock() - .await - .push((ap_id.clone(), actor_url.clone())); - Ok(()) - } - async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> { - self.actors_removed.lock().await.push(actor_url.clone()); - Ok(()) - } - async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> { - self.likes - .lock() - .await - .push((object_url.clone(), actor_url.clone())); - Ok(()) - } - async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> { - self.unlikes - .lock() - .await - .push((object_url.clone(), actor_url.clone())); - Ok(()) - } - async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> { - self.announces_received - .lock() - .await - .push((object_url.clone(), actor_url.clone())); - Ok(()) - } - async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> { - self.announces_removed - .lock() - .await - .push((object_url.clone(), actor_url.clone())); - Ok(()) - } - async fn on_announce_of_remote(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> { - self.announces_of_remote - .lock() - .await - .push((object_url.clone(), actor_url.clone())); - 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(()) - } -} - -#[derive(Default)] -struct MemContentReader; - -#[async_trait] -impl ApContentReader for MemContentReader { - async fn get_local_objects_page( - &self, - _: uuid::Uuid, - _: Option>, - _: usize, - ) -> anyhow::Result)>> { - Ok(vec![]) - } - async fn count_local_posts(&self) -> anyhow::Result { - Ok(0) - } -} - -// ── Test helpers ────────────────────────────────────────────────────────────── +// ── Test helpers ───────────────────────────────────────────────────────────── const LOCAL_DOMAIN: &str = "example.com"; const BASE_URL: &str = "https://example.com"; @@ -496,31 +175,108 @@ fn remote_note_url() -> Url { "https://other.example/notes/99".parse().unwrap() } +/// Shared tracking vectors used by closures in mock builders. +struct Tracking { + added_followers: Arc>>, + removed_followers: Arc>>, + removed_following: Arc>>, + following_status_updates: Arc>>, + added_announces: Arc>>, + removed_announces: Arc>>, +} + struct TestSetup { - follow_repo: Arc, - actor_repo: Arc, - handler: Arc, + tracking: Tracking, + handler: HandlerTracking, config: FederationConfig, } -async fn setup(blocklist: MemBlocklistRepo, local_user_id: uuid::Uuid) -> TestSetup { - let follow_repo = Arc::new(MemFollowRepo::default()); - let actor_repo = Arc::new(MemActorRepo::default()); - let handler = Arc::new(MemHandler::default()); +async fn setup_with_blocklist( + blocklist: Arc, + local_user_id: uuid::Uuid, +) -> TestSetup { + let added_followers = Arc::new(Mutex::new(vec![])); + let removed_followers = Arc::new(Mutex::new(vec![])); + let removed_following = Arc::new(Mutex::new(vec![])); + let following_status_updates = Arc::new(Mutex::new(vec![])); + let added_announces = Arc::new(Mutex::new(vec![])); + let removed_announces = Arc::new(Mutex::new(vec![])); + + let af = added_followers.clone(); + let rf = removed_followers.clone(); + let rfw = removed_following.clone(); + let fsu = following_status_updates.clone(); + let follow_repo = MockFollowRepoBuilder::new() + .on_add_follower(move |id, url, status, _| { + af.try_lock().unwrap().push((id, url.to_string(), status)); + Ok(()) + }) + .on_remove_follower(move |id, url| { + rf.try_lock().unwrap().push((id, url.to_string())); + Ok(()) + }) + .on_remove_following(move |id, url| { + rfw.try_lock().unwrap().push((id, url.to_string())); + Ok(()) + }) + .on_update_following_status(move |id, url, status| { + fsu.try_lock().unwrap().push((id, url.to_string(), status)); + Ok(()) + }) + .build(); + + let aa = added_announces.clone(); + let ra = removed_announces.clone(); + let actor_repo = MockActorRepoBuilder::new() + .on_add_announce(move |activity_id, _, _, _| { + aa.try_lock().unwrap().push(activity_id.to_string()); + Ok(()) + }) + .on_remove_announce(move |activity_id, _| { + ra.try_lock().unwrap().push(activity_id.to_string()); + Ok(()) + }) + .build(); + + // Activity repo with real dedup tracking + let processed = Arc::new(Mutex::new(HashSet::::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(); + + let handler = HandlerTracking::default(); + + let user = make_user(local_user_id, "alice"); + let user_repo = MockUserRepoBuilder::new() + .on_find_by_id(move |id| { + if id == local_user_id { + Ok(Some(user.clone())) + } else { + Ok(None) + } + }) + .build(); let data = FederationData::new( - Arc::new(MemActivityRepo::default()), - follow_repo.clone(), - actor_repo.clone(), - Arc::new(blocklist), - Arc::new(MemUserRepo::new(local_user_id, "alice")), - Arc::new(MemContentReader), - handler.clone(), + activity_repo, + follow_repo, + actor_repo, + blocklist, + user_repo, + MockContentReaderBuilder::new().build(), + handler.build_handler(), BASE_URL.to_string(), false, "test".to_string(), None, std::time::Duration::from_secs(24 * 60 * 60), + Arc::new(crate::url_scheme::DefaultUrlScheme), ); let config = FederationConfig::builder() @@ -532,74 +288,71 @@ async fn setup(blocklist: MemBlocklistRepo, local_user_id: uuid::Uuid) -> TestSe .unwrap(); TestSetup { - follow_repo, - actor_repo, + tracking: Tracking { + added_followers, + removed_followers, + removed_following, + following_status_updates, + added_announces, + removed_announces, + }, handler, config, } } -// ── AcceptActivity tests ─────────────────────────────────────────────────────── +async fn setup(local_user_id: uuid::Uuid) -> TestSetup { + setup_with_blocklist(MockBlocklistRepoBuilder::new().build(), local_user_id).await +} + +// ── AcceptActivity tests ───────────────────────────────────────────────────── #[tokio::test] async fn accept_updates_following_status_to_accepted() { use activitypub_federation::kinds::activity::AcceptType; let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); - // AP Accept flow: - // Our local actor sent Follow(remote_actor). - // Remote actor responds with Accept(Follow(...)). - // - // FollowActivity.actor = our local actor (who sent the Follow) - // FollowActivity.object = the remote actor we followed - // AcceptActivity.actor = the remote actor (who accepted) - // AcceptActivity.object = the original FollowActivity - // - // AcceptActivity::receive extracts local_user_id from FollowActivity.actor, - // so that URL must be UUID-based (our standard actor URL format). let follow = FollowActivity { id: activity_url("/follow/1"), kind: Default::default(), - actor: ObjectId::from(local_actor_url()), // OUR actor sent the Follow - object: ObjectId::from(remote_actor_url()), // we followed remote + actor: ObjectId::from(local_actor_url()), + object: ObjectId::from(remote_actor_url()), }; let accept = AcceptActivity { id: activity_url("/accept/1"), kind: AcceptType::default(), - actor: ObjectId::from(remote_actor_url()), // remote accepts + actor: ObjectId::from(remote_actor_url()), object: follow, }; use activitypub_federation::traits::Activity; accept.receive(&data).await.unwrap(); - let updates = s.follow_repo.following_status_updates.lock().await; + let updates = s.tracking.following_status_updates.lock().await; assert_eq!(updates.len(), 1); assert_eq!(updates[0].0, local_id); assert_eq!(updates[0].1, REMOTE_ACTOR); assert!(matches!(updates[0].2, FollowingStatus::Accepted)); } -// ── RejectActivity tests ─────────────────────────────────────────────────────── +// ── RejectActivity tests ───────────────────────────────────────────────────── #[tokio::test] async fn reject_removes_following() { use activitypub_federation::kinds::activity::RejectType; let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); - // Same actor structure as Accept: local sent Follow(remote), remote rejects. - // RejectActivity::receive extracts local_user_id from FollowActivity.actor. let follow = FollowActivity { id: activity_url("/follow/1"), kind: Default::default(), - actor: ObjectId::from(local_actor_url()), // OUR actor sent the Follow - object: ObjectId::from(remote_actor_url()), // we followed remote + actor: ObjectId::from(local_actor_url()), + object: ObjectId::from(remote_actor_url()), }; let reject = RejectActivity { id: activity_url("/reject/1"), @@ -611,18 +364,18 @@ async fn reject_removes_following() { use activitypub_federation::traits::Activity; reject.receive(&data).await.unwrap(); - let removed = s.follow_repo.removed_following.lock().await; + let removed = s.tracking.removed_following.lock().await; assert_eq!(removed.len(), 1); assert_eq!(removed[0].0, local_id); assert_eq!(removed[0].1, REMOTE_ACTOR); } -// ── UndoActivity tests ──────────────────────────────────────────────────────── +// ── UndoActivity tests ────────────────────────────────────────────────────── #[tokio::test] async fn undo_follow_removes_follower_and_cleans_content() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let undo = UndoActivity { @@ -640,7 +393,7 @@ async fn undo_follow_removes_follower_and_cleans_content() { use activitypub_federation::traits::Activity; undo.receive(&data).await.unwrap(); - let removed = s.follow_repo.removed_followers.lock().await; + let removed = s.tracking.removed_followers.lock().await; assert_eq!(removed.len(), 1, "follower should be removed"); assert_eq!(removed[0].0, local_id); @@ -652,7 +405,7 @@ async fn undo_follow_removes_follower_and_cleans_content() { #[tokio::test] async fn undo_like_calls_on_unlike_for_local_object() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let undo = UndoActivity { @@ -678,7 +431,7 @@ async fn undo_like_calls_on_unlike_for_local_object() { #[tokio::test] async fn undo_like_ignores_remote_object() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let undo = UndoActivity { @@ -689,7 +442,7 @@ async fn undo_like_ignores_remote_object() { "type": "Like", "id": "https://remote.example/like/2", "actor": REMOTE_ACTOR, - "object": remote_note_url().as_str(), // NOT local + "object": remote_note_url().as_str(), }), }; @@ -706,7 +459,7 @@ async fn undo_like_ignores_remote_object() { #[tokio::test] async fn undo_announce_removes_record_and_notifies() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let undo = UndoActivity { @@ -724,7 +477,7 @@ async fn undo_announce_removes_record_and_notifies() { use activitypub_federation::traits::Activity; undo.receive(&data).await.unwrap(); - let removed = s.actor_repo.removed_announces.lock().await; + let removed = s.tracking.removed_announces.lock().await; assert_eq!(removed.len(), 1); assert_eq!(removed[0], "https://remote.example/announce/1"); @@ -736,7 +489,7 @@ async fn undo_announce_removes_record_and_notifies() { #[tokio::test] async fn undo_announce_ignores_remote_object() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let undo = UndoActivity { @@ -747,7 +500,7 @@ async fn undo_announce_ignores_remote_object() { "type": "Announce", "id": "https://remote.example/announce/2", "actor": REMOTE_ACTOR, - "object": remote_note_url().as_str(), // NOT local + "object": remote_note_url().as_str(), }), }; @@ -755,7 +508,7 @@ async fn undo_announce_ignores_remote_object() { undo.receive(&data).await.unwrap(); // remove_announce should still be called (clean up the record) - let removed = s.actor_repo.removed_announces.lock().await; + let removed = s.tracking.removed_announces.lock().await; assert_eq!( removed.len(), 1, @@ -770,17 +523,17 @@ async fn undo_announce_ignores_remote_object() { ); } -// ── CreateActivity tests ─────────────────────────────────────────────────────── +// ── CreateActivity tests ───────────────────────────────────────────────────── #[tokio::test] async fn create_uses_object_id_not_activity_id() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let object_id = "https://remote.example/notes/42"; let create = CreateActivity { - id: activity_url("/create/99"), // activity id — should NOT be used + id: activity_url("/create/99"), kind: Default::default(), actor: ObjectId::from(remote_actor_url()), object: serde_json::json!({ @@ -810,7 +563,7 @@ async fn create_uses_object_id_not_activity_id() { #[tokio::test] async fn create_with_mention_fires_on_mention() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let note_id = "https://remote.example/notes/mention-test"; @@ -843,17 +596,16 @@ async fn create_with_mention_fires_on_mention() { ); assert_eq!(mentions[0].1, local_id); - // on_create should ALSO fire (mention doesn't replace content delivery) let creates = s.handler.creates.lock().await; assert_eq!(creates.len(), 1); } -// ── UpdateActivity tests ─────────────────────────────────────────────────────── +// ── UpdateActivity tests ───────────────────────────────────────────────────── #[tokio::test] async fn update_uses_object_id() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let object_id = "https://remote.example/notes/42"; @@ -874,12 +626,12 @@ async fn update_uses_object_id() { assert_eq!(updates[0].0.as_str(), object_id); } -// ── DeleteActivity tests ─────────────────────────────────────────────────────── +// ── DeleteActivity tests ───────────────────────────────────────────────────── #[tokio::test] async fn delete_object_calls_on_delete() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let note_id = "https://remote.example/notes/to-delete"; @@ -909,15 +661,14 @@ async fn delete_object_calls_on_delete() { #[tokio::test] async fn delete_actor_calls_on_actor_removed() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); - // AP actor self-deletion: object URL == actor URL let delete = DeleteActivity { id: activity_url("/delete/actor"), kind: Default::default(), actor: ObjectId::from(remote_actor_url()), - object: serde_json::json!(REMOTE_ACTOR), // plain URL string + object: serde_json::json!(REMOTE_ACTOR), to: vec![], cc: vec![], }; @@ -936,12 +687,12 @@ async fn delete_actor_calls_on_actor_removed() { ); } -// ── AnnounceActivity tests ──────────────────────────────────────────────────── +// ── AnnounceActivity tests ─────────────────────────────────────────────────── #[tokio::test] async fn announce_local_object_records_and_notifies() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let announce = AnnounceActivity { @@ -957,7 +708,7 @@ async fn announce_local_object_records_and_notifies() { use activitypub_federation::traits::Activity; announce.receive(&data).await.unwrap(); - let added = s.actor_repo.added_announces.lock().await; + let added = s.tracking.added_announces.lock().await; assert_eq!(added.len(), 1, "announce record should be created"); let notified = s.handler.announces_received.lock().await; @@ -974,14 +725,14 @@ async fn announce_local_object_records_and_notifies() { #[tokio::test] async fn announce_remote_object_calls_on_announce_of_remote() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let announce = AnnounceActivity { id: activity_url("/announce/2"), kind: AnnounceType, actor: ObjectId::from(remote_actor_url()), - object: remote_note_url(), // NOT local + object: remote_note_url(), published: None, to: vec![], cc: vec![], @@ -1000,19 +751,19 @@ async fn announce_remote_object_calls_on_announce_of_remote() { "on_announce_received should NOT fire for remote objects" ); - let added = s.actor_repo.added_announces.lock().await; + let added = s.tracking.added_announces.lock().await; assert!( added.is_empty(), "announce record should NOT be created for remote objects" ); } -// ── LikeActivity tests ──────────────────────────────────────────────────────── +// ── LikeActivity tests ────────────────────────────────────────────────────── #[tokio::test] async fn like_local_object_calls_on_like() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let like = LikeActivity { @@ -1033,14 +784,14 @@ async fn like_local_object_calls_on_like() { #[tokio::test] async fn like_remote_object_is_ignored() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let like = LikeActivity { id: activity_url("/like/2"), kind: LikeType, actor: ObjectId::from(remote_actor_url()), - object: remote_note_url(), // NOT local + object: remote_note_url(), }; use activitypub_federation::traits::Activity; @@ -1053,17 +804,17 @@ async fn like_remote_object_is_ignored() { ); } -// ── AddActivity tests ───────────────────────────────────────────────────────── +// ── AddActivity tests ──────────────────────────────────────────────────────── #[tokio::test] async fn add_uses_object_id_not_activity_id() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let object_id = "https://remote.example/watchlist/item/5"; let add = AddActivity { - id: activity_url("/add/99"), // activity id — should NOT be used + id: activity_url("/add/99"), kind: Default::default(), actor: ObjectId::from(remote_actor_url()), object: serde_json::json!({ @@ -1088,12 +839,12 @@ async fn add_uses_object_id_not_activity_id() { ); } -// ── BlockActivity tests ─────────────────────────────────────────────────────── +// ── BlockActivity tests ───────────────────────────────────────────────────── #[tokio::test] async fn block_removes_follow_relationships() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let block = BlockActivity { @@ -1106,7 +857,7 @@ async fn block_removes_follow_relationships() { use activitypub_federation::traits::Activity; block.receive(&data).await.unwrap(); - let removed_following = s.follow_repo.removed_following.lock().await; + let removed_following = s.tracking.removed_following.lock().await; assert_eq!( removed_following.len(), 1, @@ -1114,7 +865,7 @@ async fn block_removes_follow_relationships() { ); assert_eq!(removed_following[0].0, local_id); - let removed_followers = s.follow_repo.removed_followers.lock().await; + let removed_followers = s.tracking.removed_followers.lock().await; assert_eq!( removed_followers.len(), 1, @@ -1123,16 +874,15 @@ async fn block_removes_follow_relationships() { assert_eq!(removed_followers[0].0, local_id); } -// ── Domain / actor blocking ─────────────────────────────────────────────────── +// ── Domain / actor blocking ───────────────────────────────────────────────── #[tokio::test] async fn activity_from_blocked_domain_is_skipped() { let local_id = local_user_id(); - let s = setup( - MemBlocklistRepo::blocking_domain("remote.example"), - local_id, - ) - .await; + let blocklist = MockBlocklistRepoBuilder::new() + .on_is_domain_blocked(|domain| Ok(domain == "remote.example")) + .build(); + let s = setup_with_blocklist(blocklist, local_id).await; let data = s.config.to_request_data(); let create = CreateActivity { @@ -1158,13 +908,12 @@ async fn activity_from_blocked_domain_is_skipped() { #[tokio::test] async fn follow_from_blocked_actor_is_skipped_before_http() { - // Verifies the SSRF fix: actor block checked BEFORE any HTTP dereference. let local_id = local_user_id(); - let s = setup( - MemBlocklistRepo::blocking_actor(local_id, REMOTE_ACTOR), - local_id, - ) - .await; + let expected_actor = REMOTE_ACTOR.to_string(); + let blocklist = MockBlocklistRepoBuilder::new() + .on_is_actor_blocked(move |uid, url| Ok(uid == local_id && url == expected_actor)) + .build(); + let s = setup_with_blocklist(blocklist, local_id).await; let data = s.config.to_request_data(); let follow = FollowActivity { @@ -1175,23 +924,21 @@ async fn follow_from_blocked_actor_is_skipped_before_http() { }; use activitypub_federation::traits::Activity; - // In debug mode, dereference would be attempted if we got past the block check. - // The test passes only if we return early before any HTTP call. follow.receive(&data).await.unwrap(); - let added = s.follow_repo.added_followers.lock().await; + let added = s.tracking.added_followers.lock().await; assert!( added.is_empty(), "blocked actor follow must be silently discarded" ); } -// ── Idempotency ─────────────────────────────────────────────────────────────── +// ── Idempotency ───────────────────────────────────────────────────────────── #[tokio::test] async fn duplicate_activity_id_is_skipped() { let local_id = local_user_id(); - let s = setup(MemBlocklistRepo::default(), local_id).await; + let s = setup(local_id).await; let data = s.config.to_request_data(); let make_create = || CreateActivity { diff --git a/src/tests/broadcast.rs b/src/tests/broadcast.rs index 21d982f..9fdbe05 100644 --- a/src/tests/broadcast.rs +++ b/src/tests/broadcast.rs @@ -11,47 +11,47 @@ fn followers_url() -> Url { #[test] fn public_visibility_addresses_public_and_followers() { - let (to, cc) = visibility_addressing(ApVisibility::Public, &followers_url()); - assert_eq!(to, vec![AS_PUBLIC.to_string()]); - assert_eq!(cc, vec![followers_url().to_string()]); + let addressing = visibility_addressing(ApVisibility::Public, &followers_url()); + assert_eq!(addressing.to, vec![AS_PUBLIC.to_string()]); + assert_eq!(addressing.cc, vec![followers_url().to_string()]); } #[test] fn followers_only_visibility_addresses_followers_only() { - let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); - assert_eq!(to, vec![followers_url().to_string()]); + let addressing = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); + assert_eq!(addressing.to, vec![followers_url().to_string()]); assert!( - cc.is_empty(), + addressing.cc.is_empty(), "FollowersOnly must not include AS_PUBLIC in cc" ); } #[test] fn followers_only_excludes_as_public() { - let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); + let addressing = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); assert!( - !to.contains(&AS_PUBLIC.to_string()), + !addressing.to.contains(&AS_PUBLIC.to_string()), "FollowersOnly must not include AS_PUBLIC in to" ); assert!( - !cc.contains(&AS_PUBLIC.to_string()), + !addressing.cc.contains(&AS_PUBLIC.to_string()), "FollowersOnly must not include AS_PUBLIC in cc" ); } #[test] fn private_visibility_produces_empty_addressing() { - let (to, cc) = visibility_addressing(ApVisibility::Private, &followers_url()); - assert!(to.is_empty()); - assert!(cc.is_empty()); + let addressing = visibility_addressing(ApVisibility::Private, &followers_url()); + assert!(addressing.to.is_empty()); + assert!(addressing.cc.is_empty()); } #[test] fn public_and_followers_only_differ_in_to() { - let (pub_to, _) = visibility_addressing(ApVisibility::Public, &followers_url()); - let (fo_to, _) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); + let public = visibility_addressing(ApVisibility::Public, &followers_url()); + let followers_only = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); assert_ne!( - pub_to, fo_to, + public.to, followers_only.to, "Public and FollowersOnly must produce different to fields" ); } diff --git a/src/tests/integration.rs b/src/tests/integration.rs index 96613c9..5760163 100644 --- a/src/tests/integration.rs +++ b/src/tests/integration.rs @@ -1,399 +1,107 @@ // src/tests/integration.rs -/// Integration tests with in-memory trait stubs. -use std::collections::{HashMap, HashSet}; +/// Integration tests with mock builders. +use std::collections::HashSet; use std::sync::Arc; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; use tokio::sync::Mutex; use url::Url; -use crate::content::{ApContentReader, ApObjectHandler}; use crate::data::FederationData; -use crate::repository::{ - ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository, - Follower, FollowerStatus, FollowingStatus, RemoteActor, +use crate::testing::{ + MockActivityRepoBuilder, MockActorRepoBuilder, MockBlocklistRepoBuilder, + MockContentReaderBuilder, MockFollowRepoBuilder, MockObjectHandlerBuilder, MockUserRepoBuilder, }; -use crate::user::{ApActorType, ApUser, ApUserRepository}; +use crate::user::{ApActorType, ApUser}; -// ── ActivityRepository ──────────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────────────── -#[derive(Default)] -struct MemActivityRepo { - processed: Mutex>, -} - -#[async_trait] -impl ActivityRepository for MemActivityRepo { - async fn is_activity_processed(&self, id: &str) -> anyhow::Result { - Ok(self.processed.lock().await.contains(id)) - } - async fn mark_activity_processed(&self, id: &str) -> anyhow::Result<()> { - self.processed.lock().await.insert(id.to_string()); - Ok(()) +fn make_user(id: uuid::Uuid, username: &str) -> ApUser { + 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, } } -// ── FollowRepository ────────────────────────────────────────────────────────── - -#[derive(Default)] -struct MemFollowRepo; - -#[async_trait] -impl FollowRepository for MemFollowRepo { - async fn add_follower( - &self, - _: uuid::Uuid, - _: &str, - _: FollowerStatus, - _: &str, - ) -> anyhow::Result<()> { - Ok(()) - } - async fn get_follower_follow_activity_id( - &self, - _: uuid::Uuid, - _: &str, - ) -> anyhow::Result> { - Ok(None) - } - async fn remove_follower(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn get_followers(&self, _: uuid::Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn get_followers_page( - &self, - _: uuid::Uuid, - _: u32, - _: usize, - ) -> anyhow::Result> { - Ok(vec![]) - } - async fn count_followers(&self, _: uuid::Uuid) -> anyhow::Result { - 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> { - Ok(vec![]) - } - async fn get_accepted_follower_inboxes(&self, _: uuid::Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn count_accepted_followers(&self, _: uuid::Uuid) -> anyhow::Result { - Ok(0) - } - async fn get_accepted_followers_page( - &self, - _: uuid::Uuid, - _: u32, - _: usize, - ) -> anyhow::Result> { - 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> { - Ok(None) - } - async fn remove_following(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn get_following(&self, _: uuid::Uuid) -> anyhow::Result> { - Ok(vec![]) - } - async fn get_following_page( - &self, - _: uuid::Uuid, - _: u32, - _: usize, - ) -> anyhow::Result> { - Ok(vec![]) - } - async fn count_following(&self, _: uuid::Uuid) -> anyhow::Result { - 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> { - Ok(None) - } - async fn migrate_follower_actor(&self, _: &str, _: &str) -> anyhow::Result> { - Ok(vec![]) - } +fn build_user_repo(id: uuid::Uuid, username: &str) -> Arc { + let user = make_user(id, username); + let uname = username.to_string(); + let user2 = user.clone(); + MockUserRepoBuilder::new() + .on_find_by_id(move |qid| { + if qid == id { + Ok(Some(user.clone())) + } else { + Ok(None) + } + }) + .on_find_by_username(move |name| { + if name == uname { + Ok(Some(user2.clone())) + } else { + Ok(None) + } + }) + .build() } -// ── ActorRepository ─────────────────────────────────────────────────────────── - -#[derive(Default)] -struct MemActorRepo; - -#[async_trait] -impl ActorRepository for MemActorRepo { - async fn get_local_actor_keypair( - &self, - _: uuid::Uuid, - ) -> anyhow::Result> { - 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> { - Ok(None) - } - async fn add_announce( - &self, - _: &str, - _: &str, - _: &str, - _: DateTime, - ) -> anyhow::Result<()> { - Ok(()) - } - async fn remove_announce(&self, _: &str, _: &str) -> anyhow::Result<()> { - Ok(()) - } - async fn count_announces(&self, _: &str) -> anyhow::Result { - Ok(0) - } -} - -// ── BlocklistRepository ─────────────────────────────────────────────────────── - -struct MemBlocklistRepo { - blocked_domains: Mutex>, -} - -impl MemBlocklistRepo { - fn with_blocked_domains(domains: impl IntoIterator) -> 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> { - Ok(vec![]) - } - async fn is_domain_blocked(&self, domain: &str) -> anyhow::Result { - 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> { - Ok(vec![]) - } - async fn is_actor_blocked(&self, _: uuid::Uuid, _: &str) -> anyhow::Result { - Ok(false) - } -} - -// ── ApUserRepository ────────────────────────────────────────────────────────── - -struct MemUserRepo { - users: HashMap, -} - -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> { - Ok(self.users.get(&id).cloned()) - } - async fn find_by_username(&self, username: &str) -> anyhow::Result> { - Ok(self - .users - .values() - .find(|u| u.username == username) - .cloned()) - } - async fn count_users(&self) -> anyhow::Result { - 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>, - _: usize, - ) -> anyhow::Result)>> { - Ok(vec![]) - } - async fn count_local_posts(&self) -> anyhow::Result { - Ok(0) - } -} - -// ── ApObjectHandler ─────────────────────────────────────────────────────────── - -#[derive(Default)] -struct MemHandler { - creates: Mutex>, - mentions: Mutex>, -} - -#[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 ──────────────────────────────────────────────────────────────────── +// ── Helper ─────────────────────────────────────────────────────────────────── fn make_data( - activity_repo: Arc, - follow_repo: Arc, - actor_repo: Arc, - blocklist_repo: Arc, - user_repo: Arc, - content_reader: Arc, - handler: Arc, + blocklist_repo: Option>, + user_repo: Arc, + handler: Arc, ) -> FederationData { + // Activity repo with real dedup tracking + let processed = Arc::new(Mutex::new(HashSet::::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( activity_repo, - follow_repo, - actor_repo, - blocklist_repo, + MockFollowRepoBuilder::new().build(), + MockActorRepoBuilder::new().build(), + blocklist_repo.unwrap_or_else(|| MockBlocklistRepoBuilder::new().build()), user_repo, - content_reader, + MockContentReaderBuilder::new().build(), handler, "https://example.com".to_string(), false, "test".to_string(), None, std::time::Duration::from_secs(24 * 60 * 60), + Arc::new(crate::url_scheme::DefaultUrlScheme), ) } -// ── Tests ───────────────────────────────────────────────────────────────────── +// ── Tests ──────────────────────────────────────────────────────────────────── #[tokio::test] async fn check_guards_idempotency() { use crate::activities::helpers::check_guards; use activitypub_federation::config::FederationConfig; - let activity_repo = Arc::new(MemActivityRepo::default()); let data_inner = make_data( - activity_repo, - Arc::new(MemFollowRepo), - Arc::new(MemActorRepo), - Arc::new(MemBlocklistRepo::default()), - Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")), - Arc::new(MemContentReader), - Arc::new(MemHandler::default()), + None, + build_user_repo(uuid::Uuid::new_v4(), "alice"), + MockObjectHandlerBuilder::new().build(), ); let config = FederationConfig::builder() .domain("example.com") @@ -423,17 +131,13 @@ async fn check_guards_blocks_domain() { use crate::activities::helpers::check_guards; use activitypub_federation::config::FederationConfig; - let blocklist_repo = Arc::new(MemBlocklistRepo::with_blocked_domains([ - "spam.example".to_string() - ])); + let blocklist = MockBlocklistRepoBuilder::new() + .on_is_domain_blocked(|domain| Ok(domain == "spam.example")) + .build(); let data_inner = make_data( - Arc::new(MemActivityRepo::default()), - Arc::new(MemFollowRepo), - Arc::new(MemActorRepo), - blocklist_repo, - Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")), - Arc::new(MemContentReader), - Arc::new(MemHandler::default()), + Some(blocklist), + build_user_repo(uuid::Uuid::new_v4(), "alice"), + MockObjectHandlerBuilder::new().build(), ); let config = FederationConfig::builder() .domain("example.com") @@ -457,16 +161,15 @@ async fn extract_and_dispatch_mentions_notifies_local_users() { use activitypub_federation::config::FederationConfig; let local_user_id = uuid::Uuid::new_v4(); - let handler = Arc::new(MemHandler::default()); - let data_inner = make_data( - Arc::new(MemActivityRepo::default()), - Arc::new(MemFollowRepo), - Arc::new(MemActorRepo), - Arc::new(MemBlocklistRepo::default()), - Arc::new(MemUserRepo::with_user(local_user_id, "alice")), - Arc::new(MemContentReader), - handler.clone(), - ); + let mentions: Arc>> = Arc::new(Mutex::new(vec![])); + let m = mentions.clone(); + let handler = MockObjectHandlerBuilder::new() + .on_on_mention(move |ap_id, user_id, _| { + m.try_lock().unwrap().push((ap_id.clone(), user_id)); + Ok(()) + }) + .build(); + let data_inner = make_data(None, build_user_repo(local_user_id, "alice"), handler); let config = FederationConfig::builder() .domain("example.com") .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; - let mentions = handler.mentions.lock().await; + let mentions = mentions.lock().await; assert_eq!(mentions.len(), 1); assert_eq!(mentions[0].0, ap_id); assert_eq!(mentions[0].1, local_user_id); diff --git a/src/url_scheme.rs b/src/url_scheme.rs new file mode 100644 index 0000000..6ef9491 --- /dev/null +++ b/src/url_scheme.rs @@ -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; + fn inbox_url(&self, actor_url: &Url) -> anyhow::Result; + fn shared_inbox_url(&self, base_url: &str) -> Option; + fn outbox_url(&self, actor_url: &Url) -> anyhow::Result; + fn followers_url(&self, actor_url: &Url) -> anyhow::Result; + fn following_url(&self, actor_url: &Url) -> anyhow::Result; + fn activity_url(&self, base_url: &str) -> anyhow::Result; + fn extract_user_id(&self, url: &Url) -> Option; +} + +/// 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::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::parse(&format!("{}/inbox", actor_url)) + .map_err(|error| anyhow::anyhow!("invalid actor_url: {error}")) + } + + fn shared_inbox_url(&self, base_url: &str) -> Option { + Url::parse(&format!("{}/inbox", base_url)).ok() + } + + fn outbox_url(&self, actor_url: &Url) -> anyhow::Result { + 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::parse(&format!("{}/followers", actor_url)) + .map_err(|error| anyhow::anyhow!("invalid actor_url: {error}")) + } + + fn following_url(&self, actor_url: &Url) -> anyhow::Result { + 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::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 { + let path = url.path(); + path.strip_prefix("/users/") + .and_then(|s| s.split('/').next()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + } +} diff --git a/src/urls.rs b/src/urls.rs index fde3d87..1842cdd 100644 --- a/src/urls.rs +++ b/src/urls.rs @@ -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 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 INBOX_BODY_LIMIT: usize = 1024 * 1024; /// Returns the `@context` array for actor AP JSON. /// 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 { - 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::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 { - url.path() - .strip_prefix("/users/") - .and_then(|s| s.split('/').next()) - .map(|s| s.to_string()) -}