diff --git a/scripts/federation.sh b/scripts/federation.sh index 42899e4..d0f3145 100755 --- a/scripts/federation.sh +++ b/scripts/federation.sh @@ -2,6 +2,7 @@ set -e # launch a couple of local instances to test federation +# sometimes ctrl+c doesnt work properly, so you have to kill trunk, cargo-watch and ibis manually # TODO: somehow instances use wrong port resulting in cors errors (trap 'kill 0' SIGINT; sh -c 'TRUNK_SERVE_PORT=8070 IBIS_BACKEND_PORT=8071 IBIS_DATABASE_URL="postgres://ibis:password@localhost:5432/ibis" ./scripts/watch.sh' & diff --git a/src/backend/api/article.rs b/src/backend/api/article.rs index b96a673..7ec4953 100644 --- a/src/backend/api/article.rs +++ b/src/backend/api/article.rs @@ -26,14 +26,14 @@ use diffy::create_patch; /// Create a new article with empty text, and federate it to followers. #[debug_handler] pub(in crate::backend::api) async fn create_article( - Extension(_user): Extension, + Extension(user): Extension, data: Data, Form(create_article): Form, ) -> MyResult> { let local_instance = DbInstance::read_local_instance(&data.db_connection)?; let ap_id = ObjectId::parse(&format!( "http://{}:{}/article/{}", - local_instance.ap_id.inner().domain().unwrap(), + local_instance.ap_id.inner().host_str().unwrap(), local_instance.ap_id.inner().port().unwrap(), create_article.title ))?; @@ -46,9 +46,19 @@ pub(in crate::backend::api) async fn create_article( }; let article = DbArticle::create(&form, &data.db_connection)?; - CreateArticle::send_to_followers(article.clone(), &data).await?; + let edit_data = EditArticleData { + article_id: article.id, + new_text: create_article.text, + summary: create_article.summary, + previous_version_id: article.latest_edit_version(&data.db_connection)?, + resolve_conflict_id: None, + }; + let _ = edit_article(Extension(user), data.reset_request_count(), Form(edit_data)).await?; - Ok(Json(DbArticle::read_view(article.id, &data.db_connection)?)) + let article_view = DbArticle::read_view(article.id, &data.db_connection)?; + CreateArticle::send_to_followers(article_view.article.clone(), &data).await?; + + Ok(Json(article_view)) } /// Edit an existing article (local or remote). @@ -103,7 +113,7 @@ pub(in crate::backend::api) async fn edit_article( let previous_version = DbEdit::read(&edit_form.previous_version_id, &data.db_connection)?; let form = DbConflictForm { - id: EditVersion::new(&patch.to_string())?, + id: EditVersion::new(&patch.to_string()), diff: patch.to_string(), summary: edit_form.summary.clone(), creator_id: user.local_user.id, diff --git a/src/backend/database/edit.rs b/src/backend/database/edit.rs index a274759..ac7ea83 100644 --- a/src/backend/database/edit.rs +++ b/src/backend/database/edit.rs @@ -32,7 +32,7 @@ impl DbEditForm { previous_version_id: EditVersion, ) -> MyResult { let diff = create_patch(&original_article.text, updated_text); - let version = EditVersion::new(&diff.to_string())?; + let version = EditVersion::new(&diff.to_string()); let ap_id = Self::generate_ap_id(original_article, &version)?; Ok(DbEditForm { hash: version, diff --git a/src/backend/database/version.rs b/src/backend/database/version.rs index 2f57928..8b13789 100644 --- a/src/backend/database/version.rs +++ b/src/backend/database/version.rs @@ -1,35 +1 @@ -use crate::backend::error::MyResult; -use crate::common::EditVersion; -use sha2::{Digest, Sha256}; -use uuid::Uuid; -impl EditVersion { - pub fn new(diff: &str) -> MyResult { - let mut sha256 = Sha256::new(); - sha256.update(diff); - let hash_bytes = sha256.finalize(); - let uuid = Uuid::from_slice(&hash_bytes.as_slice()[..16])?; - Ok(EditVersion(uuid)) - } - - pub fn hash(&self) -> String { - hex::encode(self.0.into_bytes()) - } -} - -impl Default for EditVersion { - fn default() -> Self { - EditVersion::new("").unwrap() - } -} - -#[test] -fn test_edit_versions() -> MyResult<()> { - let default = EditVersion::default(); - assert_eq!("e3b0c44298fc1c149afbf4c8996fb924", default.hash()); - - let version = EditVersion::new("test")?; - assert_eq!("9f86d081884c7d659a2feaa0c55ad015", version.hash()); - - Ok(()) -} diff --git a/src/backend/federation/activities/reject.rs b/src/backend/federation/activities/reject.rs index a2f330d..11bb175 100644 --- a/src/backend/federation/activities/reject.rs +++ b/src/backend/federation/activities/reject.rs @@ -75,7 +75,7 @@ impl ActivityHandler for RejectEdit { let article = self.object.object.dereference(data).await?; let creator = self.object.attributed_to.dereference(data).await?; let form = DbConflictForm { - id: EditVersion::new(&self.object.content)?, + id: EditVersion::new(&self.object.content), diff: self.object.content, summary: self.object.summary, creator_id: creator.id, diff --git a/src/backend/utils.rs b/src/backend/utils.rs index 131f89f..fb6b706 100644 --- a/src/backend/utils.rs +++ b/src/backend/utils.rs @@ -51,7 +51,7 @@ mod test { Ok(DbEdit { id: 0, creator_id: 0, - hash: EditVersion::new(&diff)?, + hash: EditVersion::new(&diff), ap_id: ObjectId::parse("http://example.com")?, diff, summary: String::new(), @@ -79,7 +79,7 @@ mod test { #[test] fn test_generate_invalid_version() -> MyResult<()> { let edits = create_edits()?; - let generated = generate_article_version(&edits, &EditVersion::new("invalid")?); + let generated = generate_article_version(&edits, &EditVersion::new("invalid")); assert!(generated.is_err()); Ok(()) } diff --git a/src/common/mod.rs b/src/common/mod.rs index 4994994..487ca18 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use url::Url; use uuid::Uuid; #[cfg(feature = "ssr")] @@ -79,6 +80,26 @@ pub struct DbEdit { #[cfg_attr(feature = "ssr", derive(diesel_derive_newtype::DieselNewType))] pub struct EditVersion(pub(crate) Uuid); +impl EditVersion { + pub fn new(diff: &str) -> Self { + let mut sha256 = Sha256::new(); + sha256.update(diff); + let hash_bytes = sha256.finalize(); + let uuid = Uuid::from_slice(&hash_bytes.as_slice()[..16]).unwrap(); + EditVersion(uuid) + } + + pub fn hash(&self) -> String { + hex::encode(self.0.into_bytes()) + } +} + +impl Default for EditVersion { + fn default() -> Self { + EditVersion::new("") + } +} + #[derive(Deserialize, Serialize, Clone)] pub struct RegisterUserData { pub username: String, @@ -133,6 +154,8 @@ pub struct DbPerson { #[derive(Deserialize, Serialize)] pub struct CreateArticleData { pub title: String, + pub text: String, + pub summary: String, } #[derive(Deserialize, Serialize, Debug)] @@ -213,3 +236,12 @@ pub struct InstanceView { pub followers: Vec, pub following: Vec, } + +#[test] +fn test_edit_versions() { + let default = EditVersion::default(); + assert_eq!("e3b0c44298fc1c149afbf4c8996fb924", default.hash()); + + let version = EditVersion::new("test"); + assert_eq!("9f86d081884c7d659a2feaa0c55ad015", version.hash()); +} diff --git a/src/frontend/api.rs b/src/frontend/api.rs index 394825d..ee88b68 100644 --- a/src/frontend/api.rs +++ b/src/frontend/api.rs @@ -56,26 +56,12 @@ impl ApiClient { handle_json_res::(req).await } - pub async fn create_article(&self, title: String, new_text: String) -> MyResult { - let create_form = CreateArticleData { - title: title.clone(), - }; + pub async fn create_article(&self, data: &CreateArticleData) -> MyResult { let req = self .client .post(format!("http://{}/api/v1/article", &self.hostname)) - .form(&create_form); - let article: ArticleView = handle_json_res(req).await?; - - // create initial edit to ensure that conflicts are generated (there are no conflicts on empty file) - // TODO: maybe take initial text directly in create article, no reason to have empty article - let edit_form = EditArticleData { - article_id: article.article.id, - new_text, - summary: "initial text".to_string(), - previous_version_id: article.latest_version, - resolve_conflict_id: None, - }; - Ok(self.edit_article(&edit_form).await.unwrap()) + .form(data); + handle_json_res(req).await } pub async fn edit_article_with_conflict( diff --git a/src/frontend/app.rs b/src/frontend/app.rs index 85cf198..ccfbf6b 100644 --- a/src/frontend/app.rs +++ b/src/frontend/app.rs @@ -1,6 +1,7 @@ use crate::common::LocalUserView; use crate::frontend::api::ApiClient; use crate::frontend::components::nav::Nav; +use crate::frontend::pages::article::create::CreateArticle; use crate::frontend::pages::article::edit::EditArticle; use crate::frontend::pages::article::history::ArticleHistory; use crate::frontend::pages::article::read::ReadArticle; @@ -74,6 +75,7 @@ pub fn App() -> impl IntoView { + diff --git a/src/frontend/components/nav.rs b/src/frontend/components/nav.rs index 9e2839e..97a8cff 100644 --- a/src/frontend/components/nav.rs +++ b/src/frontend/components/nav.rs @@ -18,6 +18,12 @@ pub fn Nav() -> impl IntoView {
  • "Main Page"
  • + +
  • + "Create Article" +
  • +
  • impl IntoView { + let (title, set_title) = create_signal(String::new()); + let (text, set_text) = create_signal(String::new()); + let (summary, set_summary) = create_signal(String::new()); + let (create_response, set_create_response) = create_signal(None::<()>); + let (create_error, set_create_error) = create_signal(None::); + let (wait_for_response, set_wait_for_response) = create_signal(false); + let button_is_disabled = + Signal::derive(move || wait_for_response.get() || summary.get().is_empty()); + let submit_action = create_action(move |(title, text, summary): &(String, String, String)| { + let title = title.clone(); + let text = text.clone(); + let summary = summary.clone(); + async move { + let form = CreateArticleData { + title, + text, + summary, + }; + set_wait_for_response.update(|w| *w = true); + let res = GlobalState::api_client().create_article(&form).await; + set_wait_for_response.update(|w| *w = false); + match res { + Ok(_res) => { + set_create_response.update(|v| *v = Some(())); + set_create_error.update(|e| *e = None); + } + Err(err) => { + let msg = err.0.to_string(); + log::warn!("Unable to create: {msg}"); + set_create_error.update(|e| *e = Some(msg)); + } + } + } + }); + + view! { + + + + + {move || { + create_error + .get() + .map(|err| { + view! {

    {err}

    } + }) + }} + + + } + }> + +
    + } +} diff --git a/src/frontend/pages/article/mod.rs b/src/frontend/pages/article/mod.rs index 2e0a170..dbe30f9 100644 --- a/src/frontend/pages/article/mod.rs +++ b/src/frontend/pages/article/mod.rs @@ -1,3 +1,4 @@ +pub mod create; pub mod edit; pub mod history; pub mod read; diff --git a/tests/test.rs b/tests/test.rs index 0dec965..7ed86cc 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -3,8 +3,8 @@ extern crate ibis_lib; mod common; use crate::common::{TestData, TEST_ARTICLE_DEFAULT_TEXT}; -use ibis_lib::common::SearchArticleData; use ibis_lib::common::{ArticleView, EditArticleData, ForkArticleData, GetArticleData}; +use ibis_lib::common::{CreateArticleData, SearchArticleData}; use ibis_lib::common::{LoginUserData, RegisterUserData}; use ibis_lib::frontend::error::MyResult; use pretty_assertions::{assert_eq, assert_ne}; @@ -15,12 +15,13 @@ async fn test_create_read_and_edit_local_article() -> MyResult<()> { let data = TestData::start().await; // create article - let title = "Manu_Chao".to_string(); - let create_res = data - .alpha - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.alpha.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert!(create_res.article.local); // now article can be read @@ -30,7 +31,7 @@ async fn test_create_read_and_edit_local_article() -> MyResult<()> { id: None, }; let get_res = data.alpha.get_article(get_article_data.clone()).await?; - assert_eq!(title, get_res.article.title); + assert_eq!(create_form.title, get_res.article.title); assert_eq!(TEST_ARTICLE_DEFAULT_TEXT, get_res.article.text); assert!(get_res.article.local); @@ -52,7 +53,7 @@ async fn test_create_read_and_edit_local_article() -> MyResult<()> { assert_eq!(edit_form.summary, edit_res.edits[1].summary); let search_form = SearchArticleData { - query: title.clone(), + query: create_form.title.clone(), }; let search_res = data.alpha.search(&search_form).await?; assert_eq!(1, search_res.len()); @@ -66,18 +67,16 @@ async fn test_create_duplicate_article() -> MyResult<()> { let data = TestData::start().await; // create article - let title = "Manu_Chao".to_string(); - let create_res = data - .alpha - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.alpha.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert!(create_res.article.local); - let create_res = data - .alpha - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await; + let create_res = data.alpha.create_article(&create_form).await; assert!(create_res.is_err()); data.stop() @@ -123,12 +122,13 @@ async fn test_synchronize_articles() -> MyResult<()> { let data = TestData::start().await; // create article on alpha - let title = "Manu_Chao".to_string(); - let create_res = data - .alpha - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.alpha.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert_eq!(1, create_res.edits.len()); assert!(create_res.article.local); @@ -162,7 +162,7 @@ async fn test_synchronize_articles() -> MyResult<()> { get_article_data.instance_id = Some(instance.id); let get_res = data.beta.get_article(get_article_data).await?; assert_eq!(create_res.article.ap_id, get_res.article.ap_id); - assert_eq!(title, get_res.article.title); + assert_eq!(create_form.title, get_res.article.title); assert_eq!(2, get_res.edits.len()); assert_eq!(edit_form.new_text, get_res.article.text); assert!(!get_res.article.local); @@ -177,12 +177,13 @@ async fn test_edit_local_article() -> MyResult<()> { let beta_instance = data.alpha.follow_instance(&data.beta.hostname).await?; // create new article - let title = "Manu_Chao".to_string(); - let create_res = data - .beta - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.beta.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert!(create_res.article.local); // article should be federated to alpha @@ -230,12 +231,13 @@ async fn test_edit_remote_article() -> MyResult<()> { let beta_id_on_gamma = data.gamma.follow_instance(&data.beta.hostname).await?; // create new article - let title = "Manu_Chao".to_string(); - let create_res = data - .beta - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(&title, &create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.beta.create_article(&create_form).await?; + assert_eq!(&create_form.title, &create_res.article.title); assert!(create_res.article.local); // article should be federated to alpha and gamma @@ -299,12 +301,13 @@ async fn test_local_edit_conflict() -> MyResult<()> { let data = TestData::start().await; // create new article - let title = "Manu_Chao".to_string(); - let create_res = data - .alpha - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.alpha.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert!(create_res.article.local); // one user edits article @@ -361,12 +364,13 @@ async fn test_federated_edit_conflict() -> MyResult<()> { let beta_id_on_alpha = data.alpha.follow_instance(&data.beta.hostname).await?; // create new article - let title = "Manu_Chao".to_string(); - let create_res = data - .beta - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.beta.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert!(create_res.article.local); // fetch article to gamma @@ -378,7 +382,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { // alpha edits article let get_article_data = GetArticleData { - title: Some(title.to_string()), + title: Some(create_form.title.to_string()), instance_id: Some(beta_id_on_alpha.id), id: None, }; @@ -441,12 +445,13 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { let data = TestData::start().await; // create new article - let title = "Manu_Chao".to_string(); - let create_res = data - .alpha - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.alpha.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert!(create_res.article.local); // one user edits article @@ -483,12 +488,13 @@ async fn test_fork_article() -> MyResult<()> { let data = TestData::start().await; // create article - let title = "Manu_Chao".to_string(); - let create_res = data - .alpha - .create_article(title.clone(), TEST_ARTICLE_DEFAULT_TEXT.to_string()) - .await?; - assert_eq!(title, create_res.article.title); + let create_form = CreateArticleData { + title: "Manu_Chao".to_string(), + text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), + summary: "create article".to_string(), + }; + let create_res = data.alpha.create_article(&create_form).await?; + assert_eq!(create_form.title, create_res.article.title); assert!(create_res.article.local); // fetch on beta @@ -520,7 +526,7 @@ async fn test_fork_article() -> MyResult<()> { // now search returns two articles for this title (original and forked) let search_form = SearchArticleData { - query: title.clone(), + query: create_form.title.clone(), }; let search_res = data.beta.search(&search_form).await?; assert_eq!(2, search_res.len());