From f84c4f47f2822874154b4a1e8d44a86be252b088 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Fri, 17 Nov 2023 14:22:31 +0100 Subject: [PATCH] federate article create/update --- src/api.rs | 50 ++++++++++- .../activities/create_or_update_article.rs | 85 +++++++++++++++++++ src/federation/activities/follow.rs | 5 +- src/federation/activities/mod.rs | 1 + src/federation/objects/instance.rs | 6 +- src/federation/routes.rs | 2 + tests/common.rs | 37 ++++++++ tests/test.rs | 76 ++++++++++++----- 8 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 src/federation/activities/create_or_update_article.rs diff --git a/src/api.rs b/src/api.rs index 949b23d..137143a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,9 @@ use crate::database::DatabaseHandle; use crate::error::MyResult; +use crate::federation::activities::create_or_update_article::{ + CreateOrUpdateArticle, CreateOrUpdateType, +}; use crate::federation::objects::article::DbArticle; use crate::federation::objects::instance::DbInstance; use crate::utils::generate_object_id; @@ -16,7 +19,10 @@ use url::Url; pub fn api_routes() -> Router { Router::new() - .route("/article", get(get_article).post(create_article)) + .route( + "/article", + get(get_article).post(create_article).patch(edit_article), + ) .route("/resolve_object", get(resolve_object)) .route("/instance", get(get_local_instance)) .route("/instance/follow", post(follow_instance)) @@ -42,8 +48,46 @@ async fn create_article( instance: local_instance_id, local: true, }; - let mut articles = data.articles.lock().unwrap(); - articles.insert(article.ap_id.inner().clone(), article.clone()); + { + let mut articles = data.articles.lock().unwrap(); + articles.insert(article.ap_id.inner().clone(), article.clone()); + } + + CreateOrUpdateArticle::send_to_local_followers( + article.clone(), + CreateOrUpdateType::Create, + &data, + ) + .await?; + + Ok(Json(article)) +} + +#[derive(Deserialize, Serialize)] +pub struct EditArticle { + pub ap_id: ObjectId, + pub new_text: String, +} + +#[debug_handler] +async fn edit_article( + data: Data, + Form(edit_article): Form, +) -> MyResult> { + let article = { + let mut lock = data.articles.lock().unwrap(); + let article = lock.get_mut(edit_article.ap_id.inner()).unwrap(); + article.text = edit_article.new_text; + article.clone() + }; + + CreateOrUpdateArticle::send_to_local_followers( + article.clone(), + CreateOrUpdateType::Update, + &data, + ) + .await?; + Ok(Json(article)) } diff --git a/src/federation/activities/create_or_update_article.rs b/src/federation/activities/create_or_update_article.rs new file mode 100644 index 0000000..41bc72f --- /dev/null +++ b/src/federation/activities/create_or_update_article.rs @@ -0,0 +1,85 @@ +use crate::database::DatabaseHandle; +use crate::error::MyResult; +use crate::federation::objects::article::{Article, DbArticle}; +use crate::federation::objects::instance::DbInstance; +use crate::utils::generate_object_id; +use activitypub_federation::{ + config::Data, + fetch::object_id::ObjectId, + protocol::helpers::deserialize_one_or_many, + traits::{ActivityHandler, Object}, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub enum CreateOrUpdateType { + Create, + Update, +} + +// TODO: temporary placeholder, later rework this to send diffs +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CreateOrUpdateArticle { + pub actor: ObjectId, + #[serde(deserialize_with = "deserialize_one_or_many")] + pub to: Vec, + pub object: Article, + #[serde(rename = "type")] + pub kind: CreateOrUpdateType, + pub id: Url, +} + +impl CreateOrUpdateArticle { + pub async fn send_to_local_followers( + article: DbArticle, + kind: CreateOrUpdateType, + data: &Data, + ) -> MyResult<()> { + let local_instance = data.local_instance(); + let to = local_instance + .followers + .iter() + .map(|f| f.ap_id.inner().clone()) + .collect(); + let object = article.clone().into_json(data).await?; + let id = generate_object_id(local_instance.ap_id.inner())?; + let create_or_update = CreateOrUpdateArticle { + actor: local_instance.ap_id.clone(), + to, + object, + kind, + id, + }; + let inboxes = local_instance + .followers + .iter() + .map(|f| f.inbox.clone()) + .collect(); + local_instance.send(create_or_update, inboxes, data).await?; + Ok(()) + } +} +#[async_trait::async_trait] +impl ActivityHandler for CreateOrUpdateArticle { + 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> { + Ok(()) + } + + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + DbArticle::from_json(self.object, data).await?; + Ok(()) + } +} diff --git a/src/federation/activities/follow.rs b/src/federation/activities/follow.rs index 123f1e7..ffab0ff 100644 --- a/src/federation/activities/follow.rs +++ b/src/federation/activities/follow.rs @@ -50,15 +50,14 @@ impl ActivityHandler for Follow { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { - dbg!(&self); + let actor = self.actor.dereference(data).await?; // add to followers let local_instance = { let mut lock = data.instances.lock().unwrap(); let local_instance = lock.iter_mut().find(|i| i.1.local).unwrap().1; - local_instance.followers.push(self.actor.inner().clone()); + local_instance.followers.push(actor); local_instance.clone() }; - dbg!(&local_instance.followers.len()); // send back an accept let follower = self.actor.dereference(data).await?; diff --git a/src/federation/activities/mod.rs b/src/federation/activities/mod.rs index 5e2ad4b..b65cebb 100644 --- a/src/federation/activities/mod.rs +++ b/src/federation/activities/mod.rs @@ -1,2 +1,3 @@ pub mod accept; +pub mod create_or_update_article; pub mod follow; diff --git a/src/federation/objects/instance.rs b/src/federation/objects/instance.rs index e17f7fd..a3a2337 100644 --- a/src/federation/objects/instance.rs +++ b/src/federation/objects/instance.rs @@ -23,7 +23,7 @@ pub struct DbInstance { pub(crate) public_key: String, pub(crate) private_key: Option, pub(crate) last_refreshed_at: DateTime, - pub followers: Vec, + pub followers: Vec, pub follows: Vec, pub local: bool, } @@ -40,10 +40,6 @@ pub struct Instance { } impl DbInstance { - pub fn followers(&self) -> &Vec { - &self.followers - } - pub fn followers_url(&self) -> Result { Ok(Url::parse(&format!("{}/followers", self.ap_id.inner()))?) } diff --git a/src/federation/routes.rs b/src/federation/routes.rs index e004dc6..0e9c91c 100644 --- a/src/federation/routes.rs +++ b/src/federation/routes.rs @@ -11,6 +11,7 @@ use activitypub_federation::protocol::context::WithContext; use activitypub_federation::traits::Object; use activitypub_federation::traits::{ActivityHandler, Collection}; +use crate::federation::activities::create_or_update_article::CreateOrUpdateArticle; use crate::federation::objects::articles_collection::{ArticleCollection, DbArticleCollection}; use axum::response::IntoResponse; use axum::routing::{get, post}; @@ -50,6 +51,7 @@ async fn http_get_articles( pub enum InboxActivities { Follow(Follow), Accept(Accept), + CreateOrUpdateArticle(CreateOrUpdateArticle), } #[debug_handler] diff --git a/tests/common.rs b/tests/common.rs index c868842..c5900f8 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,10 +1,13 @@ +use fediwiki::api::{FollowInstance, ResolveObject}; use fediwiki::error::MyResult; +use fediwiki::federation::objects::instance::DbInstance; use once_cell::sync::Lazy; use reqwest::Client; use serde::de::Deserialize; use serde::ser::Serialize; use std::sync::Once; use tracing::log::LevelFilter; +use url::Url; pub static CLIENT: Lazy = Lazy::new(Client::new); @@ -51,3 +54,37 @@ where .json() .await?) } + +pub async fn patch(hostname: &str, endpoint: &str, form: &T) -> MyResult +where + R: for<'de> Deserialize<'de>, +{ + Ok(CLIENT + .patch(format!("http://{}/api/v1/{}", hostname, endpoint)) + .form(form) + .send() + .await? + .json() + .await?) +} + +pub async fn follow_instance(follow_instance: &str, followed_instance: &str) -> MyResult<()> { + // fetch beta instance on alpha + let resolve_form = ResolveObject { + id: Url::parse(&format!("http://{}", followed_instance))?, + }; + let beta_instance_resolved: DbInstance = + get_query(followed_instance, "resolve_object", Some(resolve_form)).await?; + + // send follow + let follow_form = FollowInstance { + instance_id: beta_instance_resolved.ap_id, + }; + // cant use post helper because follow doesnt return json + CLIENT + .post(format!("http://{}/api/v1/instance/follow", follow_instance)) + .form(&follow_form) + .send() + .await?; + Ok(()) +} diff --git a/tests/test.rs b/tests/test.rs index 03ed58a..9688ba0 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -2,9 +2,9 @@ extern crate fediwiki; mod common; -use crate::common::{get_query, post, setup, CLIENT}; +use crate::common::{follow_instance, get_query, patch, post, setup}; use common::get; -use fediwiki::api::{CreateArticle, FollowInstance, GetArticle, ResolveObject}; +use fediwiki::api::{CreateArticle, EditArticle, GetArticle, ResolveObject}; use fediwiki::error::MyResult; use fediwiki::federation::objects::article::DbArticle; use fediwiki::federation::objects::instance::DbInstance; @@ -69,23 +69,7 @@ async fn test_follow_instance() -> MyResult<()> { let beta_instance: DbInstance = get(hostname_beta, "instance").await?; assert_eq!(0, beta_instance.followers.len()); - // fetch beta instance on alpha - let resolve_object = ResolveObject { - id: Url::parse(&format!("http://{hostname_beta}"))?, - }; - let beta_instance_resolved: DbInstance = - get_query(hostname_beta, "resolve_object", Some(resolve_object)).await?; - - // send follow - let follow_instance = FollowInstance { - instance_id: beta_instance_resolved.ap_id, - }; - // cant use post helper because follow doesnt return json - CLIENT - .post(format!("http://{hostname_alpha}/api/v1/instance/follow")) - .form(&follow_instance) - .send() - .await?; + common::follow_instance(hostname_alpha, &hostname_beta).await?; // check that follow was federated let beta_instance: DbInstance = get(hostname_beta, "instance").await?; @@ -155,3 +139,57 @@ async fn test_synchronize_articles() -> MyResult<()> { handle_beta.abort(); Ok(()) } + +#[tokio::test] +#[serial] +async fn test_federate_article_changes() -> MyResult<()> { + setup(); + let hostname_alpha = "localhost:8131"; + let hostname_beta = "localhost:8132"; + let handle_alpha = tokio::task::spawn(async { + start(hostname_alpha).await.unwrap(); + }); + let handle_beta = tokio::task::spawn(async { + start(hostname_beta).await.unwrap(); + }); + + follow_instance(hostname_alpha, hostname_beta).await?; + + // create new article + let create_form = CreateArticle { + title: "Manu_Chao".to_string(), + text: "Lorem ipsum".to_string(), + }; + let create_res: DbArticle = post(hostname_beta, "article", &create_form).await?; + assert_eq!(create_res.title, create_form.title); + + // article should be federated to alpha + let get_article = GetArticle { + title: create_res.title.clone(), + }; + let get_res = + get_query::(hostname_alpha, "article", Some(get_article.clone())).await?; + assert_eq!(create_res.title, get_res.title); + assert_eq!(create_res.text, get_res.text); + + // edit the article + let edit_form = EditArticle { + ap_id: create_res.ap_id, + new_text: "Lorem Ipsum 2".to_string(), + }; + let edit_res: DbArticle = patch(hostname_beta, "article", &edit_form).await?; + assert_eq!(edit_res.text, edit_form.new_text); + + // edit should be federated to alpha + let get_article = GetArticle { + title: edit_res.title.clone(), + }; + let get_res = + get_query::(hostname_alpha, "article", Some(get_article.clone())).await?; + assert_eq!(edit_res.title, get_res.title); + assert_eq!(edit_res.text, get_res.text); + + handle_alpha.abort(); + handle_beta.abort(); + Ok(()) +}