diff --git a/Cargo.lock b/Cargo.lock index 1ab1867..ccb2e14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "activitypub_federation" -version = "0.7.0-beta.3" +version = "0.7.0-beta.4" dependencies = [ "activitystreams-kinds", "actix-web", diff --git a/docs/10_fetching_objects_with_unknown_type.md b/docs/10_fetching_objects_with_unknown_type.md index 96392d4..793ec70 100644 --- a/docs/10_fetching_objects_with_unknown_type.md +++ b/docs/10_fetching_objects_with_unknown_type.md @@ -32,6 +32,13 @@ impl Object for SearchableDbObjects { type Kind = SearchableObjects; type Error = anyhow::Error; + fn id(&self) -> &Url { + match self { + SearchableDbObjects::User(p) => &p.federation_id, + SearchableDbObjects::Post(n) => &n.federation_id, + } + } + async fn read_from_id( object_id: Url, data: &Data, diff --git a/examples/live_federation/objects/person.rs b/examples/live_federation/objects/person.rs index d9439ea..43005f4 100644 --- a/examples/live_federation/objects/person.rs +++ b/examples/live_federation/objects/person.rs @@ -69,6 +69,10 @@ impl Object for DbUser { type Kind = Person; type Error = Error; + fn id(&self) -> &Url { + self.ap_id.inner() + } + fn last_refreshed_at(&self) -> Option> { Some(self.last_refreshed_at) } @@ -122,10 +126,6 @@ impl Object for DbUser { } impl Actor for DbUser { - fn id(&self) -> Url { - self.ap_id.inner().clone() - } - fn public_key_pem(&self) -> &str { &self.public_key } diff --git a/examples/live_federation/objects/post.rs b/examples/live_federation/objects/post.rs index 1b19fac..1d8d7e6 100644 --- a/examples/live_federation/objects/post.rs +++ b/examples/live_federation/objects/post.rs @@ -50,6 +50,10 @@ impl Object for DbPost { type Kind = Note; type Error = Error; + fn id(&self) -> &Url { + self.ap_id.inner() + } + async fn read_from_id( _object_id: Url, _data: &Data, diff --git a/examples/local_federation/actix_web/http.rs b/examples/local_federation/actix_web/http.rs index 6298014..678a6fc 100644 --- a/examples/local_federation/actix_web/http.rs +++ b/examples/local_federation/actix_web/http.rs @@ -8,7 +8,7 @@ use activitypub_federation::{ config::{Data, FederationConfig, FederationMiddleware}, fetch::webfinger::{build_webfinger_response, extract_webfinger_name}, protocol::context::WithContext, - traits::{Actor, Object}, + traits::Object, FEDERATION_CONTENT_TYPE, }; use actix_web::{web, web::Bytes, App, HttpRequest, HttpResponse, HttpServer}; diff --git a/examples/local_federation/objects/person.rs b/examples/local_federation/objects/person.rs index 0ae402f..5497897 100644 --- a/examples/local_federation/objects/person.rs +++ b/examples/local_federation/objects/person.rs @@ -134,6 +134,10 @@ impl Object for DbUser { type Kind = Person; type Error = Error; + fn id(&self) -> &Url { + self.ap_id.inner() + } + fn last_refreshed_at(&self) -> Option> { Some(self.last_refreshed_at) } @@ -187,10 +191,6 @@ impl Object for DbUser { } impl Actor for DbUser { - fn id(&self) -> Url { - self.ap_id.inner().clone() - } - fn public_key_pem(&self) -> &str { &self.public_key } diff --git a/examples/local_federation/objects/post.rs b/examples/local_federation/objects/post.rs index cbdf8e8..0de23fd 100644 --- a/examples/local_federation/objects/post.rs +++ b/examples/local_federation/objects/post.rs @@ -47,6 +47,10 @@ impl Object for DbPost { type Kind = Note; type Error = Error; + fn id(&self) -> &Url { + self.ap_id.inner() + } + async fn read_from_id( object_id: Url, data: &Data, diff --git a/src/activity_sending.rs b/src/activity_sending.rs index 4971485..7ee7ea0 100644 --- a/src/activity_sending.rs +++ b/src/activity_sending.rs @@ -190,7 +190,7 @@ where // PKey is internally like an Arc<>, so cloning is ok data.config .actor_pkey_cache - .try_get_with_by_ref(&actor_id, async { + .try_get_with_by_ref(actor_id, async { let private_key_pem = actor.private_key_pem().ok_or_else(|| { Error::Other(format!( "Actor {actor_id} does not contain a private key for signing" diff --git a/src/actix_web/mod.rs b/src/actix_web/mod.rs index 7683d4b..6b7430e 100644 --- a/src/actix_web/mod.rs +++ b/src/actix_web/mod.rs @@ -4,6 +4,7 @@ mod http_compat; pub mod inbox; #[doc(hidden)] pub mod middleware; +pub mod response; use crate::{ config::Data, diff --git a/src/actix_web/response.rs b/src/actix_web/response.rs new file mode 100644 index 0000000..bc63d52 --- /dev/null +++ b/src/actix_web/response.rs @@ -0,0 +1,45 @@ +//! Generate HTTP responses for Activitypub ojects + +use crate::{ + protocol::{context::WithContext, tombstone::Tombstone}, + FEDERATION_CONTENT_TYPE, +}; +use actix_web::HttpResponse; +use http02::header::VARY; +use serde::Serialize; +use serde_json::Value; +use url::Url; + +/// TODO +pub fn create_http_response( + data: T, + federation_context: &Value, +) -> Result { + let json = serde_json::to_string_pretty(&WithContext::new(data, federation_context.clone()))?; + + Ok(HttpResponse::Ok() + .content_type(FEDERATION_CONTENT_TYPE) + .insert_header((VARY, "Accept")) + .body(json)) +} + +pub(crate) fn create_tombstone_response( + id: Url, + federation_context: &Value, +) -> Result { + let tombstone = Tombstone::new(id); + let json = + serde_json::to_string_pretty(&WithContext::new(tombstone, federation_context.clone()))?; + + Ok(HttpResponse::Gone() + .content_type(FEDERATION_CONTENT_TYPE) + .status(actix_web::http::StatusCode::GONE) + .insert_header((VARY, "Accept")) + .body(json)) +} + +pub(crate) fn redirect_remote_object(url: &Url) -> HttpResponse { + let mut res = HttpResponse::PermanentRedirect(); + res.insert_header((actix_web::http::header::LOCATION, url.as_str())); + res.finish() +} diff --git a/src/config.rs b/src/config.rs index 038dc4f..bdb731f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -258,7 +258,7 @@ impl FederationConfigBuilder { let private_key = RsaPrivateKey::from_pkcs8_pem(&private_key_pem).expect("Could not decode PEM data"); - self.signed_fetch_actor = Some(Some(Arc::new((actor.id(), private_key)))); + self.signed_fetch_actor = Some(Some(Arc::new((actor.id().clone(), private_key)))); self } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 1b5818a..674be2d 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -3,5 +3,6 @@ pub mod context; pub mod helpers; pub mod public_key; +pub mod tombstone; pub mod values; pub mod verification; diff --git a/src/protocol/tombstone.rs b/src/protocol/tombstone.rs new file mode 100644 index 0000000..16fbc35 --- /dev/null +++ b/src/protocol/tombstone.rs @@ -0,0 +1,25 @@ +//! Tombstone is used to serve deleted objects + +use crate::kinds::object::TombstoneType; +use serde::{Deserialize, Serialize}; +use url::Url; + +/// For serving deleted objects +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Tombstone { + /// Id of the deleted object + pub id: Url, + #[serde(rename = "type")] + pub(crate) kind: TombstoneType, +} + +impl Tombstone { + /// Create a new tombstone for the given object id + pub fn new(id: Url) -> Tombstone { + Tombstone { + id, + kind: TombstoneType::Tombstone, + } + } +} diff --git a/src/traits/either.rs b/src/traits/either.rs index 95c2411..ba5a07c 100644 --- a/src/traits/either.rs +++ b/src/traits/either.rs @@ -29,6 +29,14 @@ where type Kind = UntaggedEither; type Error = E; + /// `id` field of the object + fn id(&self) -> &Url { + match self { + Either::Left(l) => l.id(), + Either::Right(r) => r.id(), + } + } + fn last_refreshed_at(&self) -> Option> { match self { Either::Left(l) => l.last_refreshed_at(), @@ -58,6 +66,13 @@ where } } + fn is_deleted(&self) -> bool { + match self { + Either::Left(l) => l.is_deleted(), + Either::Right(r) => r.is_deleted(), + } + } + async fn into_json(self, data: &Data) -> Result { Ok(match self { Either::Left(l) => UntaggedEither::Left(l.into_json(data).await?), @@ -95,13 +110,6 @@ where D: Sync + Send + Clone, E: From + Debug, { - fn id(&self) -> Url { - match self { - Either::Left(l) => l.id(), - Either::Right(r) => r.id(), - } - } - fn public_key_pem(&self) -> &str { match self { Either::Left(l) => l.public_key_pem(), diff --git a/src/traits/mod.rs b/src/traits/mod.rs index d99d812..3bba24b 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -9,6 +9,7 @@ use url::Url; /// `Either` implementations for traits pub mod either; +pub mod tests; /// Helper for converting between database structs and federated protocol structs. /// @@ -52,6 +53,8 @@ pub mod either; /// type Kind = Note; /// type Error = anyhow::Error; /// +/// fn id(&self) -> &Url { self.ap_id.inner() } +/// /// async fn read_from_id(object_id: Url, data: &Data) -> Result, Self::Error> { /// // Attempt to read object from local database. Return Ok(None) if not found. /// let post: Option = data.read_post_from_json_id(object_id).await?; @@ -106,6 +109,9 @@ pub trait Object: Sized + Debug { /// Error type returned by handler methods type Error; + /// `id` field of the object + fn id(&self) -> &Url; + /// Returns the last time this object was updated. /// /// If this returns `Some` and the value is too long ago, the object is refetched from the @@ -134,6 +140,11 @@ pub trait Object: Sized + Debug { Ok(()) } + /// Returns true if the object was deleted + fn is_deleted(&self) -> bool { + false + } + /// Convert database type to Activitypub type. /// /// Called when a local object gets fetched by another instance over HTTP, or when an object @@ -159,6 +170,37 @@ pub trait Object: Sized + Debug { /// should write the received object to database. Note that there is no distinction between /// create and update, so an `upsert` operation should be used. async fn from_json(json: Self::Kind, data: &Data) -> Result; + + /// Convert the data to json and turn it into an HTTP Response with the correct ActivityPub + /// headers. + /// + /// actix-web doesn't allow pretty-print for json so we need to do this manually. + #[cfg(feature = "actix-web")] + async fn http_response( + self, + federation_context: &serde_json::Value, + data: &Data, + ) -> Result + where + Self::Error: From, + Self::Kind: serde::Serialize + Send, + { + use crate::actix_web::response::{ + create_http_response, + create_tombstone_response, + redirect_remote_object, + }; + let id = self.id(); + let res = if data.config.is_local_url(id) { + redirect_remote_object(id) + } else if !self.is_deleted() { + let json = self.into_json(data).await?; + create_http_response(json, federation_context)? + } else { + create_tombstone_response(id.clone(), federation_context)? + }; + Ok(res) + } } /// Handler for receiving incoming activities. @@ -234,9 +276,6 @@ pub trait ActivityHandler { /// Trait to allow retrieving common Actor data. pub trait Actor: Object + Send + 'static { - /// `id` field of the actor - fn id(&self) -> Url; - /// The actor's public key for verifying signatures of incoming activities. /// /// Use [generate_actor_keypair](crate::http_signatures::generate_actor_keypair) to create the @@ -254,7 +293,7 @@ pub trait Actor: Object + Send + 'static { /// Generates a public key struct for use in the actor json representation fn public_key(&self) -> PublicKey { - PublicKey::new(self.id(), self.public_key_pem().to_string()) + PublicKey::new(self.id().clone(), self.public_key_pem().to_string()) } /// The actor's shared inbox, if any @@ -334,208 +373,3 @@ pub trait Collection: Sized { data: &Data, ) -> Result; } - -/// Some impls of these traits for use in tests. Dont use this from external crates. -/// -/// TODO: Should be using `cfg[doctest]` but blocked by -#[doc(hidden)] -#[allow(clippy::unwrap_used)] -pub mod tests { - use super::{async_trait, ActivityHandler, Actor, Data, Debug, Object, PublicKey, Url}; - use crate::{ - error::Error, - fetch::object_id::ObjectId, - http_signatures::{generate_actor_keypair, Keypair}, - protocol::verification::verify_domains_match, - }; - use activitystreams_kinds::{activity::FollowType, actor::PersonType}; - use serde::{Deserialize, Serialize}; - use std::sync::LazyLock; - - #[derive(Clone)] - pub struct DbConnection; - - impl DbConnection { - pub async fn read_post_from_json_id(&self, _: Url) -> Result, Error> { - Ok(None) - } - pub async fn read_local_user(&self, _: &str) -> Result { - todo!() - } - pub async fn upsert(&self, _: &T) -> Result<(), Error> { - Ok(()) - } - pub async fn add_follower(&self, _: DbUser, _: DbUser) -> Result<(), Error> { - Ok(()) - } - } - - #[derive(Clone, Debug, Deserialize, Serialize)] - #[serde(rename_all = "camelCase")] - pub struct Person { - #[serde(rename = "type")] - pub kind: PersonType, - pub preferred_username: String, - pub id: ObjectId, - pub inbox: Url, - pub public_key: PublicKey, - } - #[derive(Debug, Clone)] - pub struct DbUser { - pub name: String, - pub federation_id: Url, - pub inbox: Url, - pub public_key: String, - #[allow(dead_code)] - private_key: Option, - pub followers: Vec, - pub local: bool, - } - - pub static DB_USER_KEYPAIR: LazyLock = - LazyLock::new(|| generate_actor_keypair().unwrap()); - - pub static DB_USER: LazyLock = LazyLock::new(|| DbUser { - name: String::new(), - federation_id: "https://localhost/123".parse().unwrap(), - inbox: "https://localhost/123/inbox".parse().unwrap(), - public_key: DB_USER_KEYPAIR.public_key.clone(), - private_key: Some(DB_USER_KEYPAIR.private_key.clone()), - followers: vec![], - local: false, - }); - - #[async_trait] - impl Object for DbUser { - type DataType = DbConnection; - type Kind = Person; - type Error = Error; - - async fn read_from_id( - _object_id: Url, - _data: &Data, - ) -> Result, Self::Error> { - Ok(Some(DB_USER.clone())) - } - - async fn into_json(self, _data: &Data) -> Result { - Ok(Person { - preferred_username: self.name.clone(), - kind: Default::default(), - id: self.federation_id.clone().into(), - inbox: self.inbox.clone(), - public_key: self.public_key(), - }) - } - - async fn verify( - json: &Self::Kind, - expected_domain: &Url, - _data: &Data, - ) -> Result<(), Self::Error> { - verify_domains_match(json.id.inner(), expected_domain)?; - Ok(()) - } - - async fn from_json( - json: Self::Kind, - _data: &Data, - ) -> Result { - Ok(DbUser { - name: json.preferred_username, - federation_id: json.id.into(), - inbox: json.inbox, - public_key: json.public_key.public_key_pem, - private_key: None, - followers: vec![], - local: false, - }) - } - } - - impl Actor for DbUser { - fn id(&self) -> Url { - self.federation_id.clone() - } - - fn public_key_pem(&self) -> &str { - &self.public_key - } - - fn private_key_pem(&self) -> Option { - self.private_key.clone() - } - - fn inbox(&self) -> Url { - self.inbox.clone() - } - } - - #[derive(Deserialize, Serialize, Clone, Debug)] - #[serde(rename_all = "camelCase")] - pub struct Follow { - pub actor: ObjectId, - pub object: ObjectId, - #[serde(rename = "type")] - pub kind: FollowType, - pub id: Url, - } - - #[async_trait] - impl ActivityHandler for Follow { - type DataType = DbConnection; - type Error = Error; - - fn id(&self) -> &Url { - &self.id - } - - fn actor(&self) -> &Url { - self.actor.inner() - } - - async fn verify(&self, _: &Data) -> Result<(), Self::Error> { - Ok(()) - } - - async fn receive(self, _data: &Data) -> Result<(), Self::Error> { - Ok(()) - } - } - - #[derive(Clone, Debug, Deserialize, Serialize)] - #[serde(rename_all = "camelCase")] - pub struct Note {} - #[derive(Debug, Clone)] - pub struct DbPost {} - - #[async_trait] - impl Object for DbPost { - type DataType = DbConnection; - type Kind = Note; - type Error = Error; - - async fn read_from_id( - _: Url, - _: &Data, - ) -> Result, Self::Error> { - todo!() - } - - async fn into_json(self, _: &Data) -> Result { - todo!() - } - - async fn verify( - _: &Self::Kind, - _: &Url, - _: &Data, - ) -> Result<(), Self::Error> { - todo!() - } - - async fn from_json(_: Self::Kind, _: &Data) -> Result { - todo!() - } - } -} diff --git a/src/traits/tests.rs b/src/traits/tests.rs new file mode 100644 index 0000000..ec29a38 --- /dev/null +++ b/src/traits/tests.rs @@ -0,0 +1,201 @@ +#![doc(hidden)] +#![allow(clippy::unwrap_used)] +//! Some impls of these traits for use in tests. Dont use this from external crates. +//! +//! TODO: Should be using `cfg[doctest]` but blocked by + +use super::{async_trait, ActivityHandler, Actor, Data, Debug, Object, PublicKey, Url}; +use crate::{ + error::Error, + fetch::object_id::ObjectId, + http_signatures::{generate_actor_keypair, Keypair}, + protocol::verification::verify_domains_match, +}; +use activitystreams_kinds::{activity::FollowType, actor::PersonType}; +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +#[derive(Clone)] +pub struct DbConnection; + +impl DbConnection { + pub async fn read_post_from_json_id(&self, _: Url) -> Result, Error> { + Ok(None) + } + pub async fn read_local_user(&self, _: &str) -> Result { + todo!() + } + pub async fn upsert(&self, _: &T) -> Result<(), Error> { + Ok(()) + } + pub async fn add_follower(&self, _: DbUser, _: DbUser) -> Result<(), Error> { + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Person { + #[serde(rename = "type")] + pub kind: PersonType, + pub preferred_username: String, + pub id: ObjectId, + pub inbox: Url, + pub public_key: PublicKey, +} +#[derive(Debug, Clone)] +pub struct DbUser { + pub name: String, + pub federation_id: Url, + pub inbox: Url, + pub public_key: String, + #[allow(dead_code)] + private_key: Option, + pub followers: Vec, + pub local: bool, +} + +pub static DB_USER_KEYPAIR: LazyLock = LazyLock::new(|| generate_actor_keypair().unwrap()); + +pub static DB_USER: LazyLock = LazyLock::new(|| DbUser { + name: String::new(), + federation_id: "https://localhost/123".parse().unwrap(), + inbox: "https://localhost/123/inbox".parse().unwrap(), + public_key: DB_USER_KEYPAIR.public_key.clone(), + private_key: Some(DB_USER_KEYPAIR.private_key.clone()), + followers: vec![], + local: false, +}); + +#[async_trait] +impl Object for DbUser { + type DataType = DbConnection; + type Kind = Person; + type Error = Error; + + fn id(&self) -> &Url { + &self.federation_id + } + + async fn read_from_id( + _object_id: Url, + _data: &Data, + ) -> Result, Self::Error> { + Ok(Some(DB_USER.clone())) + } + + async fn into_json(self, _data: &Data) -> Result { + Ok(Person { + preferred_username: self.name.clone(), + kind: Default::default(), + id: self.federation_id.clone().into(), + inbox: self.inbox.clone(), + public_key: self.public_key(), + }) + } + + async fn verify( + json: &Self::Kind, + expected_domain: &Url, + _data: &Data, + ) -> Result<(), Self::Error> { + verify_domains_match(json.id.inner(), expected_domain)?; + Ok(()) + } + + async fn from_json( + json: Self::Kind, + _data: &Data, + ) -> Result { + Ok(DbUser { + name: json.preferred_username, + federation_id: json.id.into(), + inbox: json.inbox, + public_key: json.public_key.public_key_pem, + private_key: None, + followers: vec![], + local: false, + }) + } +} + +impl Actor for DbUser { + fn public_key_pem(&self) -> &str { + &self.public_key + } + + fn private_key_pem(&self) -> Option { + self.private_key.clone() + } + + fn inbox(&self) -> Url { + self.inbox.clone() + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Follow { + pub actor: ObjectId, + pub object: ObjectId, + #[serde(rename = "type")] + pub kind: FollowType, + pub id: Url, +} + +#[async_trait] +impl ActivityHandler for Follow { + type DataType = DbConnection; + type Error = Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify(&self, _: &Data) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, _data: &Data) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Note {} +#[derive(Debug, Clone)] +pub struct DbPost { + pub federation_id: Url, +} + +#[async_trait] +impl Object for DbPost { + type DataType = DbConnection; + type Kind = Note; + type Error = Error; + + fn id(&self) -> &Url { + todo!() + } + + async fn read_from_id(_: Url, _: &Data) -> Result, Self::Error> { + todo!() + } + + async fn into_json(self, _: &Data) -> Result { + todo!() + } + + async fn verify(_: &Self::Kind, _: &Url, _: &Data) -> Result<(), Self::Error> { + todo!() + } + + async fn from_json(_: Self::Kind, _: &Data) -> Result { + todo!() + } +}