diff --git a/src/api.rs b/src/api.rs index 1345ce6..a1bbd6d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -25,11 +25,13 @@ pub fn api_routes() -> Router { "/article", get(get_article).post(create_article).patch(edit_article), ) + .route("/article/fork", post(fork_article)) .route("/edit_conflicts", get(edit_conflicts)) .route("/resolve_instance", get(resolve_instance)) .route("/resolve_article", get(resolve_article)) .route("/instance", get(get_local_instance)) .route("/instance/follow", post(follow_instance)) + .route("/search", get(search_article)) } #[derive(Deserialize, Serialize)] @@ -42,6 +44,16 @@ async fn create_article( data: Data, Form(create_article): Form, ) -> MyResult> { + { + let articles = data.articles.lock().unwrap(); + let title_exists = articles + .iter() + .any(|a| a.1.local && a.1.title == create_article.title); + if title_exists { + return Err(anyhow!("A local article with this title already exists").into()); + } + } + let local_instance_id = data.local_instance().ap_id; let ap_id = ObjectId::parse(&format!( "http://{}:{}/article/{}", @@ -204,3 +216,77 @@ async fn edit_conflicts(data: Data) -> MyResult, + data: Data, +) -> MyResult>> { + let articles = data.articles.lock().unwrap(); + let article = articles + .iter() + .filter(|a| a.1.title == query.title) + .map(|a| a.1) + .cloned() + .collect(); + Ok(Json(article)) +} + +#[derive(Deserialize, Serialize)] +pub struct ForkArticleData { + // TODO: could add optional param new_title so there is no problem with title collision + // in case local article with same title exists + pub ap_id: ObjectId, +} + +#[debug_handler] +async fn fork_article( + data: Data, + Form(fork_form): Form, +) -> MyResult> { + let article = { + let lock = data.articles.lock().unwrap(); + let article = lock.get(fork_form.ap_id.inner()).unwrap(); + article.clone() + }; + if article.local { + return Err(anyhow!("Cannot fork local article because there cant be multiple local articles with same title").into()); + } + + let original_article = { + let lock = data.articles.lock().unwrap(); + lock.get(fork_form.ap_id.inner()) + .expect("article exists") + .clone() + }; + + let local_instance_id = data.local_instance().ap_id; + let ap_id = ObjectId::parse(&format!( + "http://{}:{}/article/{}", + local_instance_id.inner().domain().unwrap(), + local_instance_id.inner().port().unwrap(), + original_article.title + ))?; + let forked_article = DbArticle { + title: original_article.title.clone(), + text: original_article.text.clone(), + ap_id, + latest_version: original_article.latest_version.clone(), + edits: original_article.edits.clone(), + instance: local_instance_id, + local: true, + }; + { + let mut articles = data.articles.lock().unwrap(); + articles.insert(forked_article.ap_id.inner().clone(), forked_article.clone()); + } + + CreateArticle::send_to_followers(forked_article.clone(), &data).await?; + + Ok(Json(forked_article)) +} diff --git a/src/federation/objects/article.rs b/src/federation/objects/article.rs index d77541e..fa62c43 100644 --- a/src/federation/objects/article.rs +++ b/src/federation/objects/article.rs @@ -15,7 +15,7 @@ use activitypub_federation::{ use serde::{Deserialize, Serialize}; use url::Url; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct DbArticle { pub title: String, pub text: String, @@ -91,7 +91,7 @@ impl Object for DbArticle { } async fn from_json(json: Self::Kind, data: &Data) -> Result { - let article = DbArticle { + let mut article = DbArticle { title: json.name, text: json.content, ap_id: json.id, @@ -107,7 +107,10 @@ impl Object for DbArticle { lock.insert(article.ap_id.inner().clone(), article.clone()); } - json.edits.dereference(&article, data).await?; + let edits = json.edits.dereference(&article, data).await?; + + // include edits in return value (they are already written to db, no need to do that here) + article.edits = edits.0; Ok(article) } diff --git a/src/federation/objects/edit.rs b/src/federation/objects/edit.rs index 2984688..1e494d1 100644 --- a/src/federation/objects/edit.rs +++ b/src/federation/objects/edit.rs @@ -23,7 +23,7 @@ impl Default for EditVersion { } /// Represents a single change to the article. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct DbEdit { pub id: ObjectId, pub diff: String, diff --git a/src/federation/objects/edits_collection.rs b/src/federation/objects/edits_collection.rs index 5543586..1e029fe 100644 --- a/src/federation/objects/edits_collection.rs +++ b/src/federation/objects/edits_collection.rs @@ -23,7 +23,7 @@ pub struct ApubEditCollection { } #[derive(Clone, Debug)] -pub struct DbEditCollection(Vec); +pub struct DbEditCollection(pub Vec); #[async_trait::async_trait] impl Collection for DbEditCollection { diff --git a/tests/common.rs b/tests/common.rs index 7793ea1..f30c9f9 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -140,7 +140,7 @@ where Ok(alpha_instance) } -async fn post(hostname: &str, endpoint: &str, form: &T) -> MyResult +pub async fn post(hostname: &str, endpoint: &str, form: &T) -> MyResult where R: for<'de> Deserialize<'de>, { diff --git a/tests/test.rs b/tests/test.rs index 8c9486b..ed6b16c 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -4,16 +4,17 @@ mod common; use crate::common::{ create_article, edit_article, edit_article_with_conflict, follow_instance, get_article, - get_query, TestData, TEST_ARTICLE_DEFAULT_TEXT, + get_query, post, TestData, TEST_ARTICLE_DEFAULT_TEXT, }; use common::get; -use fediwiki::api::{ApiConflict, EditArticleData, ResolveObject}; +use fediwiki::api::{ + ApiConflict, EditArticleData, ForkArticleData, ResolveObject, SearchArticleData, +}; use fediwiki::error::MyResult; use fediwiki::federation::objects::article::DbArticle; use fediwiki::federation::objects::edit::ApubEdit; use fediwiki::federation::objects::instance::DbInstance; use serial_test::serial; - use url::Url; #[tokio::test] @@ -48,6 +49,31 @@ async fn test_create_read_and_edit_article() -> MyResult<()> { assert_eq!(edit_form.new_text, edit_res.text); assert_eq!(2, edit_res.edits.len()); + let search_form = SearchArticleData { + title: title.clone(), + }; + let search_res: Vec = + get_query(data.hostname_alpha, "search", Some(search_form)).await?; + assert_eq!(1, search_res.len()); + assert_eq!(edit_res, search_res[0]); + + data.stop() +} + +#[tokio::test] +#[serial] +async fn test_create_duplicate_article() -> MyResult<()> { + let data = TestData::start(); + + // create article + let title = "Manu_Chao".to_string(); + let create_res = create_article(data.hostname_alpha, title.clone()).await?; + assert_eq!(title, create_res.title); + assert!(create_res.local); + + let create_res = create_article(data.hostname_alpha, title.clone()).await; + assert!(create_res.is_err()); + data.stop() } @@ -378,3 +404,49 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { data.stop() } + +#[tokio::test] +#[serial] +async fn test_fork_article() -> MyResult<()> { + let data = TestData::start(); + + // create article + let title = "Manu_Chao".to_string(); + let create_res = create_article(data.hostname_alpha, title.clone()).await?; + assert_eq!(title, create_res.title); + assert!(create_res.local); + + // fetch on beta + let resolve_object = ResolveObject { + id: create_res.ap_id.into_inner(), + }; + let resolved_article = + get_query::(data.hostname_beta, "resolve_article", Some(resolve_object)) + .await?; + assert_eq!(create_res.edits.len(), resolved_article.edits.len()); + + // fork the article to local instance + let fork_form = ForkArticleData { + ap_id: resolved_article.ap_id.clone(), + }; + let fork_res: DbArticle = post(data.hostname_beta, "article/fork", &fork_form).await?; + assert_eq!(resolved_article.title, fork_res.title); + assert_eq!(resolved_article.text, fork_res.text); + assert_eq!(resolved_article.edits, fork_res.edits); + assert_eq!(resolved_article.latest_version, fork_res.latest_version); + assert_ne!(resolved_article.ap_id, fork_res.ap_id); + assert!(fork_res.local); + + let beta_instance: DbInstance = get(data.hostname_beta, "instance").await?; + assert_eq!(fork_res.instance, beta_instance.ap_id); + + // now search returns two articles for this title (original and forked) + let search_form = SearchArticleData { + title: title.clone(), + }; + let search_res: Vec = + get_query(data.hostname_beta, "search", Some(search_form)).await?; + assert_eq!(2, search_res.len()); + + data.stop() +}