From af2f09ea4cbb97d3ee91e30bb58e85508989d63a Mon Sep 17 00:00:00 2001 From: clarkzjw Date: Wed, 26 Jul 2023 12:37:38 -0700 Subject: add example from https://github.com/LemmyNet/activitypub-federation-rust --- fedi/live_federation/activities/create_post.rs | 69 ++++++++++++ fedi/live_federation/activities/mod.rs | 1 + fedi/live_federation/database.rs | 26 +++++ fedi/live_federation/error.rs | 20 ++++ fedi/live_federation/http.rs | 69 ++++++++++++ fedi/live_federation/main.rs | 70 +++++++++++++ fedi/live_federation/objects/mod.rs | 2 + fedi/live_federation/objects/person.rs | 140 +++++++++++++++++++++++++ fedi/live_federation/objects/post.rs | 104 ++++++++++++++++++ fedi/live_federation/utils.rs | 13 +++ 10 files changed, 514 insertions(+) create mode 100644 fedi/live_federation/activities/create_post.rs create mode 100644 fedi/live_federation/activities/mod.rs create mode 100644 fedi/live_federation/database.rs create mode 100644 fedi/live_federation/error.rs create mode 100644 fedi/live_federation/http.rs create mode 100644 fedi/live_federation/main.rs create mode 100644 fedi/live_federation/objects/mod.rs create mode 100644 fedi/live_federation/objects/person.rs create mode 100644 fedi/live_federation/objects/post.rs create mode 100644 fedi/live_federation/utils.rs (limited to 'fedi/live_federation') diff --git a/fedi/live_federation/activities/create_post.rs b/fedi/live_federation/activities/create_post.rs new file mode 100644 index 0000000..66928a6 --- /dev/null +++ b/fedi/live_federation/activities/create_post.rs @@ -0,0 +1,69 @@ +use crate::{ + database::DatabaseHandle, + error::Error, + objects::{person::DbUser, post::Note}, + utils::generate_object_id, + DbPost, +}; +use activitypub_federation::{ + activity_queue::send_activity, + config::Data, + fetch::object_id::ObjectId, + kinds::activity::CreateType, + protocol::{context::WithContext, helpers::deserialize_one_or_many}, + traits::{ActivityHandler, Object}, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CreatePost { + pub(crate) actor: ObjectId, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + pub(crate) object: Note, + #[serde(rename = "type")] + pub(crate) kind: CreateType, + pub(crate) id: Url, +} + +impl CreatePost { + pub async fn send(note: Note, inbox: Url, data: &Data) -> Result<(), Error> { + print!("Sending reply to {}", ¬e.attributed_to); + let create = CreatePost { + actor: note.attributed_to.clone(), + to: note.to.clone(), + object: note, + kind: CreateType::Create, + id: generate_object_id(data.domain())?, + }; + let create_with_context = WithContext::new_default(create); + send_activity(create_with_context, &data.local_user(), vec![inbox], data).await?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl ActivityHandler for CreatePost { + type DataType = DatabaseHandle; + type Error = crate::error::Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify(&self, data: &Data) -> Result<(), Self::Error> { + DbPost::verify(&self.object, &self.id, data).await?; + Ok(()) + } + + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + DbPost::from_json(self.object, data).await?; + Ok(()) + } +} diff --git a/fedi/live_federation/activities/mod.rs b/fedi/live_federation/activities/mod.rs new file mode 100644 index 0000000..7e15ee0 --- /dev/null +++ b/fedi/live_federation/activities/mod.rs @@ -0,0 +1 @@ +pub mod create_post; diff --git a/fedi/live_federation/database.rs b/fedi/live_federation/database.rs new file mode 100644 index 0000000..967c534 --- /dev/null +++ b/fedi/live_federation/database.rs @@ -0,0 +1,26 @@ +use crate::{objects::person::DbUser, Error}; +use anyhow::anyhow; +use std::sync::{Arc, Mutex}; + +pub type DatabaseHandle = Arc; + +/// Our "database" which contains all known users (local and federated) +pub struct Database { + pub users: Mutex>, +} + +impl Database { + pub fn local_user(&self) -> DbUser { + let lock = self.users.lock().unwrap(); + lock.first().unwrap().clone() + } + + pub fn read_user(&self, name: &str) -> Result { + let db_user = self.local_user(); + if name == db_user.name { + Ok(db_user) + } else { + Err(anyhow!("Invalid user {name}").into()) + } + } +} diff --git a/fedi/live_federation/error.rs b/fedi/live_federation/error.rs new file mode 100644 index 0000000..3ef1819 --- /dev/null +++ b/fedi/live_federation/error.rs @@ -0,0 +1,20 @@ +use std::fmt::{Display, Formatter}; + +/// Necessary because of this issue: https://github.com/actix/actix-web/issues/1711 +#[derive(Debug)] +pub struct Error(pub(crate) anyhow::Error); + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl From for Error +where + T: Into, +{ + fn from(t: T) -> Self { + Error(t.into()) + } +} diff --git a/fedi/live_federation/http.rs b/fedi/live_federation/http.rs new file mode 100644 index 0000000..d626396 --- /dev/null +++ b/fedi/live_federation/http.rs @@ -0,0 +1,69 @@ +use crate::{ + database::DatabaseHandle, + error::Error, + objects::person::{DbUser, Person, PersonAcceptedActivities}, +}; +use activitypub_federation::{ + axum::{ + inbox::{receive_activity, ActivityData}, + json::FederationJson, + }, + config::Data, + fetch::webfinger::{build_webfinger_response, extract_webfinger_name, Webfinger}, + protocol::context::WithContext, + traits::Object, +}; +use axum::{ + extract::{Path, Query}, + response::{IntoResponse, Response}, + Json, +}; +use axum_macros::debug_handler; +use http::StatusCode; +use serde::Deserialize; + +impl IntoResponse for Error { + fn into_response(self) -> Response { + (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", self.0)).into_response() + } +} + +#[debug_handler] +pub async fn http_get_user( + Path(name): Path, + data: Data, +) -> Result>, Error> { + let db_user = data.read_user(&name)?; + let json_user = db_user.into_json(&data).await?; + Ok(FederationJson(WithContext::new_default(json_user))) +} + +#[debug_handler] +pub async fn http_post_user_inbox( + data: Data, + activity_data: ActivityData, +) -> impl IntoResponse { + receive_activity::, DbUser, DatabaseHandle>( + activity_data, + &data, + ) + .await +} + +#[derive(Deserialize)] +pub struct WebfingerQuery { + resource: String, +} + +#[debug_handler] +pub async fn webfinger( + Query(query): Query, + data: Data, +) -> Result, Error> { + let name = extract_webfinger_name(&query.resource, &data)?; + let db_user = data.read_user(&name)?; + Ok(Json(build_webfinger_response( + query.resource, + db_user.ap_id.into_inner(), + ))) +} diff --git a/fedi/live_federation/main.rs b/fedi/live_federation/main.rs new file mode 100644 index 0000000..4326226 --- /dev/null +++ b/fedi/live_federation/main.rs @@ -0,0 +1,70 @@ +use crate::{ + database::Database, + http::{http_get_user, http_post_user_inbox, webfinger}, + objects::{person::DbUser, post::DbPost}, + utils::generate_object_id, +}; +use activitypub_federation::config::{FederationConfig, FederationMiddleware}; +use axum::{ + routing::{get, post}, + Router, +}; +use error::Error; +use std::{ + net::ToSocketAddrs, + sync::{Arc, Mutex}, +}; +use tracing::log::{info, LevelFilter}; + +mod activities; +mod database; +mod error; +#[allow(clippy::diverging_sub_expression, clippy::items_after_statements)] +mod http; +mod objects; +mod utils; + +const DOMAIN: &str = "example.com"; +const LOCAL_USER_NAME: &str = "alison"; +const BIND_ADDRESS: &str = "localhost:8003"; + +#[tokio::main] +async fn main() -> Result<(), Error> { + env_logger::builder() + .filter_level(LevelFilter::Warn) + .filter_module("activitypub_federation", LevelFilter::Info) + .filter_module("live_federation", LevelFilter::Info) + .format_timestamp(None) + .init(); + + info!("Setup local user and database"); + let local_user = DbUser::new(DOMAIN, LOCAL_USER_NAME)?; + let database = Arc::new(Database { + users: Mutex::new(vec![local_user]), + }); + + info!("Setup configuration"); + let config = FederationConfig::builder() + .domain(DOMAIN) + .app_data(database) + .build() + .await?; + + info!("Listen with HTTP server on {BIND_ADDRESS}"); + let config = config.clone(); + let app = Router::new() + .route("/:user", get(http_get_user)) + .route("/:user/inbox", post(http_post_user_inbox)) + .route("/.well-known/webfinger", get(webfinger)) + .layer(FederationMiddleware::new(config)); + + let addr = BIND_ADDRESS + .to_socket_addrs()? + .next() + .expect("Failed to lookup domain name"); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await?; + + Ok(()) +} diff --git a/fedi/live_federation/objects/mod.rs b/fedi/live_federation/objects/mod.rs new file mode 100644 index 0000000..b5239ab --- /dev/null +++ b/fedi/live_federation/objects/mod.rs @@ -0,0 +1,2 @@ +pub mod person; +pub mod post; diff --git a/fedi/live_federation/objects/person.rs b/fedi/live_federation/objects/person.rs new file mode 100644 index 0000000..d9439ea --- /dev/null +++ b/fedi/live_federation/objects/person.rs @@ -0,0 +1,140 @@ +use crate::{activities::create_post::CreatePost, database::DatabaseHandle, error::Error}; +use activitypub_federation::{ + config::Data, + fetch::object_id::ObjectId, + http_signatures::generate_actor_keypair, + kinds::actor::PersonType, + protocol::{public_key::PublicKey, verification::verify_domains_match}, + traits::{ActivityHandler, Actor, Object}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use url::Url; + +#[derive(Debug, Clone)] +pub struct DbUser { + pub name: String, + pub ap_id: ObjectId, + pub inbox: Url, + // exists for all users (necessary to verify http signatures) + pub public_key: String, + // exists only for local users + pub private_key: Option, + last_refreshed_at: DateTime, + pub followers: Vec, + pub local: bool, +} + +/// List of all activities which this actor can receive. +#[derive(Deserialize, Serialize, Debug)] +#[serde(untagged)] +#[enum_delegate::implement(ActivityHandler)] +pub enum PersonAcceptedActivities { + CreateNote(CreatePost), +} + +impl DbUser { + pub fn new(hostname: &str, name: &str) -> Result { + let ap_id = Url::parse(&format!("https://{}/{}", hostname, &name))?.into(); + let inbox = Url::parse(&format!("https://{}/{}/inbox", hostname, &name))?; + let keypair = generate_actor_keypair()?; + Ok(DbUser { + name: name.to_string(), + ap_id, + inbox, + public_key: keypair.public_key, + private_key: Some(keypair.private_key), + last_refreshed_at: Utc::now(), + followers: vec![], + local: true, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Person { + #[serde(rename = "type")] + kind: PersonType, + preferred_username: String, + id: ObjectId, + inbox: Url, + public_key: PublicKey, +} + +#[async_trait::async_trait] +impl Object for DbUser { + type DataType = DatabaseHandle; + type Kind = Person; + type Error = Error; + + 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 users = data.users.lock().unwrap(); + let res = users + .clone() + .into_iter() + .find(|u| u.ap_id.inner() == &object_id); + Ok(res) + } + + async fn into_json(self, _data: &Data) -> Result { + Ok(Person { + preferred_username: self.name.clone(), + kind: Default::default(), + id: self.ap_id.clone(), + 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, + ap_id: json.id, + inbox: json.inbox, + public_key: json.public_key.public_key_pem, + private_key: None, + last_refreshed_at: Utc::now(), + followers: vec![], + local: false, + }) + } +} + +impl Actor for DbUser { + fn id(&self) -> Url { + self.ap_id.inner().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() + } +} diff --git a/fedi/live_federation/objects/post.rs b/fedi/live_federation/objects/post.rs new file mode 100644 index 0000000..9a08b9d --- /dev/null +++ b/fedi/live_federation/objects/post.rs @@ -0,0 +1,104 @@ +use crate::{ + activities::create_post::CreatePost, + database::DatabaseHandle, + error::Error, + generate_object_id, + objects::person::DbUser, +}; +use activitypub_federation::{ + config::Data, + fetch::object_id::ObjectId, + kinds::{object::NoteType, public}, + protocol::{helpers::deserialize_one_or_many, verification::verify_domains_match}, + traits::{Actor, Object}, +}; +use activitystreams_kinds::link::MentionType; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Clone, Debug)] +pub struct DbPost { + pub text: String, + pub ap_id: ObjectId, + pub creator: ObjectId, + pub local: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Note { + #[serde(rename = "type")] + kind: NoteType, + id: ObjectId, + pub(crate) attributed_to: ObjectId, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub(crate) to: Vec, + content: String, + in_reply_to: Option>, + tag: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Mention { + pub href: Url, + #[serde(rename = "type")] + pub kind: MentionType, +} + +#[async_trait::async_trait] +impl Object for DbPost { + type DataType = DatabaseHandle; + type Kind = Note; + type Error = Error; + + async fn read_from_id( + _object_id: Url, + _data: &Data, + ) -> Result, Self::Error> { + Ok(None) + } + + async fn into_json(self, _data: &Data) -> Result { + unimplemented!() + } + + 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 { + println!( + "Received post with content {} and id {}", + &json.content, &json.id + ); + let creator = json.attributed_to.dereference(data).await?; + let post = DbPost { + text: json.content, + ap_id: json.id.clone(), + creator: json.attributed_to.clone(), + local: false, + }; + + let mention = Mention { + href: creator.ap_id.clone().into_inner(), + kind: Default::default(), + }; + let note = Note { + kind: Default::default(), + id: generate_object_id(data.domain())?.into(), + attributed_to: data.local_user().ap_id, + to: vec![public()], + content: format!("Hello {}", creator.name), + in_reply_to: Some(json.id.clone()), + tag: vec![mention], + }; + CreatePost::send(note, creator.shared_inbox_or_inbox(), data).await?; + + Ok(post) + } +} diff --git a/fedi/live_federation/utils.rs b/fedi/live_federation/utils.rs new file mode 100644 index 0000000..0b2b098 --- /dev/null +++ b/fedi/live_federation/utils.rs @@ -0,0 +1,13 @@ +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use url::{ParseError, Url}; + +/// Just generate random url as object id. In a real project, you probably want to use +/// an url which contains the database id for easy retrieval (or store the random id in db). +pub fn generate_object_id(domain: &str) -> Result { + let id: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(7) + .map(char::from) + .collect(); + Url::parse(&format!("https://{}/objects/{}", domain, id)) +} -- cgit v1.2.3