From 5d2099c17c32f4a3f6c98b701eab28780f7724e2 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Tue, 5 Dec 2023 01:17:02 +0100 Subject: [PATCH] conflict moved to db --- Cargo.lock | 14 ++- Cargo.toml | 4 +- migrations/2023-11-28-150402_article/down.sql | 3 +- migrations/2023-11-28-150402_article/up.sql | 13 ++- src/api.rs | 73 ++++++------ src/database/article.rs | 13 +-- src/database/conflict.rs | 108 ++++++++++++++++++ src/database/edit.rs | 98 +++++++--------- src/database/mod.rs | 75 +----------- src/database/schema.rs | 16 ++- src/database/version.rs | 43 +++++++ src/federation/activities/mod.rs | 9 +- src/federation/activities/reject.rs | 19 ++- src/federation/objects/article.rs | 2 +- src/federation/objects/edit.rs | 12 +- src/lib.rs | 11 +- src/utils.rs | 4 +- tests/common.rs | 46 +++++--- tests/test.rs | 33 +++--- 19 files changed, 342 insertions(+), 254 deletions(-) create mode 100644 src/database/conflict.rs create mode 100644 src/database/version.rs diff --git a/Cargo.lock b/Cargo.lock index 8637e9c..103ee61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,7 @@ dependencies = [ "diesel_derives", "itoa", "pq-sys", + "uuid", ] [[package]] @@ -679,6 +680,7 @@ dependencies = [ "enum_delegate", "env_logger", "futures", + "hex", "once_cell", "pretty_assertions", "rand", @@ -688,6 +690,7 @@ dependencies = [ "tokio", "tracing", "url", + "uuid", ] [[package]] @@ -924,6 +927,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "0.2.11" @@ -2276,11 +2285,12 @@ dependencies = [ [[package]] name = "uuid" -version = "1.5.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc" +checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" dependencies = [ "getrandom", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e088be7..4e3e5b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,19 +10,21 @@ async-trait = "0.1.74" axum = "0.6.20" axum-macros = "0.3.8" chrono = { version = "0.4.31", features = ["serde"] } -diesel = {version = "2.1.4", features = ["postgres", "chrono"] } +diesel = {version = "2.1.4", features = ["postgres", "chrono", "uuid"] } diesel-derive-newtype = "2.1.0" diesel_migrations = "2.1.0" diffy = "0.3.0" enum_delegate = "0.2.0" env_logger = { version = "0.10.1", default-features = false } futures = "0.3.29" +hex = "0.4.3" rand = "0.8.5" serde = "1.0.192" sha2 = "0.10.8" tokio = { version = "1.34.0", features = ["full"] } tracing = "0.1.40" url = "2.4.1" +uuid = { version = "1.6.1", features = ["serde"] } [dev-dependencies] once_cell = "1.18.0" diff --git a/migrations/2023-11-28-150402_article/down.sql b/migrations/2023-11-28-150402_article/down.sql index d01733f..9a5a480 100644 --- a/migrations/2023-11-28-150402_article/down.sql +++ b/migrations/2023-11-28-150402_article/down.sql @@ -1,4 +1,5 @@ +drop table conflict; drop table edit; drop table article; drop table instance_follow; -drop table instance; \ No newline at end of file +drop table instance; diff --git a/migrations/2023-11-28-150402_article/up.sql b/migrations/2023-11-28-150402_article/up.sql index 641b7f4..82801ea 100644 --- a/migrations/2023-11-28-150402_article/up.sql +++ b/migrations/2023-11-28-150402_article/up.sql @@ -28,9 +28,16 @@ create table article ( create table edit ( id serial primary key, + hash uuid not null, ap_id varchar(255) not null unique, diff text not null, article_id int REFERENCES article ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, - version text not null, - previous_version text not null -) \ No newline at end of file + previous_version_id uuid not null +); + +create table conflict ( + id uuid primary key, + diff text not null, + article_id int REFERENCES article ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + previous_version_id uuid not null +); \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index c2c8da2..237e29e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,7 +1,9 @@ use crate::database::article::{ArticleView, DbArticle, DbArticleForm}; -use crate::database::edit::{DbEdit, EditVersion}; +use crate::database::conflict::{ApiConflict, DbConflict, DbConflictForm}; +use crate::database::edit::{DbEdit, DbEditForm}; use crate::database::instance::{DbInstance, InstanceView}; -use crate::database::{DbConflict, MyDataHandle}; +use crate::database::version::EditVersion; +use crate::database::MyDataHandle; use crate::error::MyResult; use crate::federation::activities::create_article::CreateArticle; use crate::federation::activities::follow::Follow; @@ -9,14 +11,12 @@ use crate::federation::activities::submit_article_update; use crate::utils::generate_article_version; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; -use anyhow::anyhow; use axum::extract::Query; use axum::routing::{get, post}; use axum::{Form, Json, Router}; use axum_macros::debug_handler; use diffy::create_patch; use futures::future::try_join_all; -use rand::random; use serde::{Deserialize, Serialize}; use url::Url; @@ -60,8 +60,7 @@ async fn create_article( instance_id: local_instance.id, local: true, }; - dbg!(&form.ap_id); - let article = dbg!(DbArticle::create(&form, &data.db_connection))?; + let article = DbArticle::create(&form, &data.db_connection)?; CreateArticle::send_to_followers(article.clone(), &data).await?; @@ -77,17 +76,9 @@ pub struct EditArticleData { pub new_text: String, /// The version that this edit is based on, ie [DbArticle.latest_version] or /// [ApiConflict.previous_version] - pub previous_version: EditVersion, + pub previous_version_id: EditVersion, /// If you are resolving a conflict, pass the id to delete conflict from the database - pub resolve_conflict_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct ApiConflict { - pub id: i32, - pub three_way_merge: String, - pub article_id: ObjectId, - pub previous_version: EditVersion, + pub resolve_conflict_id: Option, } /// Edit an existing article (local or remote). @@ -105,21 +96,17 @@ async fn edit_article( Form(edit_form): Form, ) -> MyResult>> { // resolve conflict if any - if let Some(resolve_conflict_id) = &edit_form.resolve_conflict_id { - let mut lock = data.conflicts.lock().unwrap(); - if !lock.iter().any(|c| &c.id == resolve_conflict_id) { - return Err(anyhow!("invalid resolve conflict"))?; - } - lock.retain(|c| &c.id != resolve_conflict_id); + if let Some(resolve_conflict_id) = edit_form.resolve_conflict_id { + DbConflict::delete(resolve_conflict_id, &data.db_connection)?; } let original_article = DbArticle::read_view(edit_form.article_id, &data.db_connection)?; - if edit_form.previous_version == original_article.latest_version { + if edit_form.previous_version_id == original_article.latest_version { // No intermediate changes, simply submit new version submit_article_update( &data, edit_form.new_text.clone(), - edit_form.previous_version, + edit_form.previous_version_id, &original_article.article, ) .await?; @@ -128,20 +115,18 @@ async fn edit_article( // There have been other changes since this edit was initiated. Get the common ancestor // version and generate a diff to find out what exactly has changed. let ancestor = - generate_article_version(&original_article.edits, &edit_form.previous_version)?; + generate_article_version(&original_article.edits, &edit_form.previous_version_id)?; let patch = create_patch(&ancestor, &edit_form.new_text); - let db_conflict = DbConflict { - id: random(), + let previous_version = DbEdit::read(&edit_form.previous_version_id, &data.db_connection)?; + let form = DbConflictForm { + id: EditVersion::new(&patch.to_string())?, diff: patch.to_string(), - article_id: original_article.article.ap_id.clone(), - previous_version: edit_form.previous_version, + article_id: original_article.article.id, + previous_version_id: previous_version.hash, }; - { - let mut lock = data.conflicts.lock().unwrap(); - lock.push(db_conflict.clone()); - } - Ok(Json(db_conflict.to_api_conflict(&data).await?)) + let conflict = DbConflict::create(&form, &data.db_connection)?; + Ok(Json(conflict.to_api_conflict(&data).await?)) } } @@ -186,8 +171,8 @@ async fn resolve_article( data: Data, ) -> MyResult> { let article: DbArticle = ObjectId::from(query.id).dereference(&data).await?; - let edits = DbEdit::for_article(&article, &data.db_connection)?; - let latest_version = edits.last().unwrap().version.clone(); + let edits = DbEdit::read_for_article(&article, &data.db_connection)?; + let latest_version = edits.last().unwrap().hash.clone(); Ok(Json(ArticleView { article, edits, @@ -226,7 +211,7 @@ async fn follow_instance( /// Get a list of all unresolved edit conflicts. #[debug_handler] async fn edit_conflicts(data: Data) -> MyResult>> { - let conflicts = { data.conflicts.lock().unwrap().to_vec() }; + let conflicts = DbConflict::list(&data.db_connection)?; let conflicts: Vec = try_join_all(conflicts.into_iter().map(|c| { let data = data.reset_request_count(); async move { c.to_api_conflict(&data).await } @@ -289,10 +274,18 @@ async fn fork_article( // copy edits to new article // TODO: convert to sql - let edits = DbEdit::for_article(&original_article, &data.db_connection)?; + let edits = DbEdit::read_for_article(&original_article, &data.db_connection)?; for e in edits { - let form = e.copy_to_local_fork(&article)?; - DbEdit::create(&form, &data.db_connection)?; + let ap_id = DbEditForm::generate_ap_id(&article, &e.hash)?; + // TODO: id gives db unique violation + let form = DbEditForm { + ap_id, + diff: e.diff, + article_id: article.id, + hash: e.hash, + previous_version_id: e.previous_version_id, + }; + dbg!(DbEdit::create(&form, &data.db_connection))?; } CreateArticle::send_to_followers(article.clone(), &data).await?; diff --git a/src/database/article.rs b/src/database/article.rs index 38a2c7a..830d635 100644 --- a/src/database/article.rs +++ b/src/database/article.rs @@ -1,4 +1,4 @@ -use crate::database::edit::{DbEdit, EditVersion}; +use crate::database::edit::DbEdit; use crate::database::schema::article; use crate::error::MyResult; @@ -6,7 +6,7 @@ use crate::federation::objects::edits_collection::DbEditCollection; use activitypub_federation::fetch::collection_id::CollectionId; use activitypub_federation::fetch::object_id::ObjectId; use diesel::pg::PgConnection; -use diesel::BelongingToDsl; + use diesel::ExpressionMethods; use diesel::{ insert_into, AsChangeset, BoolExpressionMethods, Identifiable, Insertable, @@ -14,6 +14,7 @@ use diesel::{ }; use serde::{Deserialize, Serialize}; +use crate::database::version::EditVersion; use std::ops::DerefMut; use std::sync::Mutex; @@ -86,8 +87,7 @@ impl DbArticle { article::table.find(id).get_result(conn.deref_mut())? }; let latest_version = article.latest_edit_version(conn)?; - let mut conn = conn.lock().unwrap(); - let edits: Vec = DbEdit::belonging_to(&article).get_results(conn.deref_mut())?; + let edits: Vec = DbEdit::read_for_article(&article, conn)?; Ok(ArticleView { article, edits, @@ -137,9 +137,8 @@ impl DbArticle { // TODO: shouldnt have to read all edits from db pub fn latest_edit_version(&self, conn: &Mutex) -> MyResult { - let mut conn = conn.lock().unwrap(); - let edits: Vec = DbEdit::belonging_to(&self).get_results(conn.deref_mut())?; - match edits.last().map(|e| e.version.clone()) { + let edits: Vec = DbEdit::read_for_article(self, conn)?; + match edits.last().map(|e| e.hash.clone()) { Some(latest_version) => Ok(latest_version), None => Ok(EditVersion::default()), } diff --git a/src/database/conflict.rs b/src/database/conflict.rs new file mode 100644 index 0000000..3f10645 --- /dev/null +++ b/src/database/conflict.rs @@ -0,0 +1,108 @@ +use crate::database::article::DbArticle; +use crate::database::edit::DbEdit; +use crate::database::schema::conflict; +use crate::database::version::EditVersion; +use crate::database::MyDataHandle; +use crate::error::MyResult; +use crate::federation::activities::submit_article_update; +use crate::utils::generate_article_version; +use activitypub_federation::config::Data; + +use diesel::{ + delete, insert_into, Identifiable, Insertable, PgConnection, QueryDsl, Queryable, RunQueryDsl, + Selectable, +}; +use diffy::{apply, merge, Patch}; +use serde::{Deserialize, Serialize}; +use std::ops::DerefMut; +use std::sync::Mutex; + +/// A local only object which represents a merge conflict. It is created +/// when a local user edit conflicts with another concurrent edit. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable, Identifiable)] +#[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg), belongs_to(DbArticle, foreign_key = article_id))] +pub struct DbConflict { + pub id: EditVersion, + pub diff: String, + pub article_id: i32, + pub previous_version_id: EditVersion, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ApiConflict { + pub id: EditVersion, + pub three_way_merge: String, + pub article_id: i32, + pub previous_version_id: EditVersion, +} + +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg))] +pub struct DbConflictForm { + pub id: EditVersion, + pub diff: String, + pub article_id: i32, + pub previous_version_id: EditVersion, +} + +impl DbConflict { + pub fn create(form: &DbConflictForm, conn: &Mutex) -> MyResult { + let mut conn = conn.lock().unwrap(); + Ok(insert_into(conflict::table) + .values(form) + .get_result(conn.deref_mut())?) + } + pub fn list(conn: &Mutex) -> MyResult> { + let mut conn = conn.lock().unwrap(); + Ok(conflict::table.get_results(conn.deref_mut())?) + } + + /// Delete a merge conflict after it is resolved. + pub fn delete(id: EditVersion, conn: &Mutex) -> MyResult { + let mut conn = conn.lock().unwrap(); + // TODO: should throw error on invalid id param + Ok(delete(conflict::table.find(id)).get_result(conn.deref_mut())?) + } + + pub async fn to_api_conflict( + &self, + data: &Data, + ) -> MyResult> { + let article = DbArticle::read(self.article_id, &data.db_connection)?; + // Make sure to get latest version from origin so that all conflicts can be resolved + let original_article = article.ap_id.dereference_forced(data).await?; + + // create common ancestor version + let edits = DbEdit::read_for_article(&original_article, &data.db_connection)?; + let ancestor = generate_article_version(&edits, &self.previous_version_id)?; + + let patch = Patch::from_str(&self.diff)?; + // apply self.diff to ancestor to get `ours` + let ours = apply(&ancestor, &patch)?; + match merge(&ancestor, &ours, &original_article.text) { + Ok(new_text) => { + // patch applies cleanly so we are done + // federate the change + submit_article_update( + data, + new_text, + self.previous_version_id.clone(), + &original_article, + ) + .await?; + DbConflict::delete(self.id.clone(), &data.db_connection)?; + Ok(None) + } + Err(three_way_merge) => { + // there is a merge conflict, user needs to do three-way-merge + Ok(Some(ApiConflict { + id: self.id.clone(), + three_way_merge, + article_id: original_article.id, + previous_version_id: original_article + .latest_edit_version(&data.db_connection)?, + })) + } + } + } +} diff --git a/src/database/edit.rs b/src/database/edit.rs index 6c0b50a..b2b2d6f 100644 --- a/src/database/edit.rs +++ b/src/database/edit.rs @@ -1,80 +1,71 @@ use crate::database::schema::edit; +use crate::database::version::EditVersion; use crate::database::DbArticle; use crate::error::MyResult; use activitypub_federation::fetch::object_id::ObjectId; +use diesel::ExpressionMethods; use diesel::{ - insert_into, AsChangeset, Identifiable, Insertable, PgConnection, Queryable, RunQueryDsl, + insert_into, AsChangeset, Insertable, PgConnection, QueryDsl, Queryable, RunQueryDsl, Selectable, }; -use diesel::{Associations, BelongingToDsl}; -use diesel_derive_newtype::DieselNewType; use diffy::create_patch; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha224}; use std::ops::DerefMut; use std::sync::Mutex; /// Represents a single change to the article. -#[derive( - Clone, - Debug, - Serialize, - Deserialize, - PartialEq, - Queryable, - Selectable, - Identifiable, - Associations, -)] -#[diesel(table_name = edit, check_for_backend(diesel::pg::Pg), belongs_to(DbArticle, foreign_key = article_id))] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable)] +#[diesel(table_name = edit, check_for_backend(diesel::pg::Pg))] pub struct DbEdit { + // TODO: we could use hash as primary key, but that gives errors on forking because + // the same edit is used for multiple articles pub id: i32, + /// UUID built from sha224 hash of diff + pub hash: EditVersion, pub ap_id: ObjectId, pub diff: String, pub article_id: i32, - pub version: EditVersion, - // TODO: could be an Option instead - pub previous_version: EditVersion, + /// First edit of an article always has `EditVersion::default()` here + pub previous_version_id: EditVersion, } #[derive(Debug, Clone, Insertable, AsChangeset)] #[diesel(table_name = edit, check_for_backend(diesel::pg::Pg))] pub struct DbEditForm { + pub hash: EditVersion, pub ap_id: ObjectId, pub diff: String, pub article_id: i32, - pub version: EditVersion, - pub previous_version: EditVersion, + pub previous_version_id: EditVersion, } impl DbEditForm { pub fn new( original_article: &DbArticle, updated_text: &str, - previous_version: EditVersion, + previous_version_id: EditVersion, ) -> MyResult { let diff = create_patch(&original_article.text, updated_text); - let (ap_id, hash) = Self::generate_ap_id_and_hash(original_article, diff.to_bytes())?; + let version = EditVersion::new(&diff.to_string())?; + let ap_id = Self::generate_ap_id(original_article, &version)?; Ok(DbEditForm { + hash: version, ap_id, diff: diff.to_string(), article_id: original_article.id, - version: EditVersion(hash), - previous_version, + previous_version_id, }) } - fn generate_ap_id_and_hash( + pub(crate) fn generate_ap_id( article: &DbArticle, - diff: Vec, - ) -> MyResult<(ObjectId, String)> { - let mut sha224 = Sha224::new(); - sha224.update(diff); - let hash = format!("{:X}", sha224.finalize()); - Ok(( - ObjectId::parse(&format!("{}/{}", article.ap_id, hash))?, - hash, - )) + version: &EditVersion, + ) -> MyResult> { + Ok(ObjectId::parse(&format!( + "{}/{}", + article.ap_id, + version.hash() + ))?) } } @@ -88,31 +79,20 @@ impl DbEdit { .set(form) .get_result(conn.deref_mut())?) } - - pub fn for_article(article: &DbArticle, conn: &Mutex) -> MyResult> { + pub fn read(version: &EditVersion, conn: &Mutex) -> MyResult { let mut conn = conn.lock().unwrap(); - Ok(DbEdit::belonging_to(&article).get_results(conn.deref_mut())?) + Ok(edit::table + .filter(edit::dsl::hash.eq(version)) + .get_result(conn.deref_mut())?) } - pub fn copy_to_local_fork(self, article: &DbArticle) -> MyResult { - let (ap_id, _) = - DbEditForm::generate_ap_id_and_hash(article, self.diff.clone().into_bytes())?; - Ok(DbEditForm { - ap_id, - diff: self.diff, - article_id: article.id, - version: self.version, - previous_version: self.previous_version, - }) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, DieselNewType)] -pub struct EditVersion(pub String); - -impl Default for EditVersion { - fn default() -> Self { - let sha224 = Sha224::new(); - let hash = format!("{:X}", sha224.finalize()); - EditVersion(hash) + + pub fn read_for_article( + article: &DbArticle, + conn: &Mutex, + ) -> MyResult> { + let mut conn = conn.lock().unwrap(); + Ok(edit::table + .filter(edit::dsl::article_id.eq(article.id)) + .get_results(conn.deref_mut())?) } } diff --git a/src/database/mod.rs b/src/database/mod.rs index 4f4c2af..fe457ea 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,90 +1,27 @@ -use crate::api::ApiConflict; use crate::database::article::DbArticle; -use crate::database::edit::DbEdit; -use crate::error::MyResult; -use crate::federation::activities::submit_article_update; -use crate::utils::generate_article_version; -use activitypub_federation::config::Data; -use activitypub_federation::fetch::object_id::ObjectId; -use diesel::PgConnection; -use diffy::{apply, merge, Patch}; -use edit::EditVersion; +use diesel::PgConnection; use std::ops::Deref; use std::sync::{Arc, Mutex}; pub mod article; +pub mod conflict; pub mod edit; pub mod instance; mod schema; +pub mod version; #[derive(Clone)] pub struct MyData { pub db_connection: Arc>, - pub fake_db: Arc, } impl Deref for MyData { - type Target = Arc; + type Target = Arc>; fn deref(&self) -> &Self::Target { - &self.fake_db + &self.db_connection } } + pub type MyDataHandle = MyData; - -pub struct FakeDatabase { - pub conflicts: Mutex>, -} - -#[derive(Clone, Debug)] -pub struct DbConflict { - pub id: i32, - pub diff: String, - pub article_id: ObjectId, - pub previous_version: EditVersion, -} - -impl DbConflict { - pub async fn to_api_conflict( - &self, - data: &Data, - ) -> MyResult> { - // Make sure to get latest version from origin so that all conflicts can be resolved - let original_article = self.article_id.dereference_forced(&data).await?; - - // create common ancestor version - let edits = DbEdit::for_article(&original_article, &data.db_connection)?; - let ancestor = generate_article_version(&edits, &self.previous_version)?; - - let patch = Patch::from_str(&self.diff)?; - // apply self.diff to ancestor to get `ours` - let ours = apply(&ancestor, &patch)?; - match dbg!(merge(&ancestor, &ours, &original_article.text)) { - Ok(new_text) => { - // patch applies cleanly so we are done - // federate the change - submit_article_update( - data, - new_text, - self.previous_version.clone(), - &original_article, - ) - .await?; - // remove conflict from db - let mut lock = data.conflicts.lock().unwrap(); - lock.retain(|c| c.id != self.id); - Ok(None) - } - Err(three_way_merge) => { - // there is a merge conflict, user needs to do three-way-merge - Ok(Some(ApiConflict { - id: self.id, - three_way_merge, - article_id: original_article.ap_id.clone(), - previous_version: original_article.latest_edit_version(&data.db_connection)?, - })) - } - } - } -} diff --git a/src/database/schema.rs b/src/database/schema.rs index d1582f9..dcb23de 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -12,15 +12,24 @@ diesel::table! { } } +diesel::table! { + conflict (id) { + id -> Uuid, + diff -> Text, + article_id -> Int4, + previous_version_id -> Uuid, + } +} + diesel::table! { edit (id) { id -> Int4, + hash -> Uuid, #[max_length = 255] ap_id -> Varchar, diff -> Text, article_id -> Int4, - version -> Text, - previous_version -> Text, + previous_version_id -> Uuid, } } @@ -49,6 +58,7 @@ diesel::table! { } diesel::joinable!(article -> instance (instance_id)); +diesel::joinable!(conflict -> article (article_id)); diesel::joinable!(edit -> article (article_id)); -diesel::allow_tables_to_appear_in_same_query!(article, edit, instance, instance_follow,); +diesel::allow_tables_to_appear_in_same_query!(article, conflict, edit, instance, instance_follow,); diff --git a/src/database/version.rs b/src/database/version.rs new file mode 100644 index 0000000..c35b57d --- /dev/null +++ b/src/database/version.rs @@ -0,0 +1,43 @@ +use crate::error::MyResult; +use std::hash::Hash; + +use diesel_derive_newtype::DieselNewType; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// The version hash of a specific edit. Generated by taking an SHA256 hash of the diff +/// and using the first 16 bytes so that it fits into UUID. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, DieselNewType)] +pub struct EditVersion(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/federation/activities/mod.rs b/src/federation/activities/mod.rs index 626d2ba..ef25f34 100644 --- a/src/federation/activities/mod.rs +++ b/src/federation/activities/mod.rs @@ -1,6 +1,7 @@ use crate::database::article::DbArticle; -use crate::database::edit::{DbEdit, DbEditForm, EditVersion}; +use crate::database::edit::{DbEdit, DbEditForm}; use crate::database::instance::DbInstance; +use crate::database::version::EditVersion; use crate::database::MyDataHandle; use crate::error::Error; use crate::federation::activities::update_local_article::UpdateLocalArticle; @@ -30,12 +31,12 @@ pub async fn submit_article_update( } else { // dont insert edit into db, might be invalid in case of conflict let edit = DbEdit { - id: 0, + id: -1, + hash: form.hash, ap_id: form.ap_id, diff: form.diff, article_id: form.article_id, - version: form.version, - previous_version: form.previous_version, + previous_version_id: form.previous_version_id, }; let instance = DbInstance::read(original_article.instance_id, &data.db_connection)?; UpdateRemoteArticle::send(edit, instance, data).await?; diff --git a/src/federation/activities/reject.rs b/src/federation/activities/reject.rs index 53203eb..75bf66a 100644 --- a/src/federation/activities/reject.rs +++ b/src/federation/activities/reject.rs @@ -1,4 +1,6 @@ +use crate::database::conflict::{DbConflict, DbConflictForm}; use crate::database::instance::DbInstance; +use crate::database::version::EditVersion; use crate::database::MyDataHandle; use crate::error::MyResult; use crate::federation::objects::edit::ApubEdit; @@ -8,11 +10,7 @@ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, protocol::helpers::deserialize_one_or_many, traits::ActivityHandler, }; -use rand::random; -use crate::database::article::DbArticle; -use crate::database::DbConflict; -use crate::federation::activities::update_local_article::UpdateLocalArticle; use serde::{Deserialize, Serialize}; use url::Url; @@ -68,16 +66,15 @@ impl ActivityHandler for RejectEdit { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { - dbg!(&self); // cant convert this to DbEdit as it tries to apply patch and fails - let mut lock = data.conflicts.lock().unwrap(); - let conflict = DbConflict { - id: random(), + let article = self.object.object.dereference(data).await?; + let form = DbConflictForm { + id: EditVersion::new(&self.object.content)?, diff: self.object.content, - article_id: self.object.object, - previous_version: self.object.previous_version, + article_id: article.id, + previous_version_id: self.object.previous_version, }; - lock.push(conflict); + DbConflict::create(&form, &data.db_connection)?; Ok(()) } } diff --git a/src/federation/objects/article.rs b/src/federation/objects/article.rs index e73299b..45fe11d 100644 --- a/src/federation/objects/article.rs +++ b/src/federation/objects/article.rs @@ -1,6 +1,6 @@ use crate::database::article::DbArticleForm; -use crate::database::edit::EditVersion; use crate::database::instance::DbInstance; +use crate::database::version::EditVersion; use crate::database::{article::DbArticle, MyDataHandle}; use crate::error::Error; use crate::federation::objects::edits_collection::DbEditCollection; diff --git a/src/federation/objects/edit.rs b/src/federation/objects/edit.rs index 1f9ad65..54fd9bf 100644 --- a/src/federation/objects/edit.rs +++ b/src/federation/objects/edit.rs @@ -1,5 +1,6 @@ use crate::database::article::DbArticle; -use crate::database::edit::{DbEdit, DbEditForm, EditVersion}; +use crate::database::edit::{DbEdit, DbEditForm}; +use crate::database::version::EditVersion; use crate::database::MyDataHandle; use crate::error::Error; use activitypub_federation::config::Data; @@ -44,9 +45,8 @@ impl Object for DbEdit { kind: EditType::Edit, id: self.ap_id, content: self.diff, - version: self.version, - // TODO: this is wrong - previous_version: self.previous_version, + version: self.hash, + previous_version: self.previous_version_id, object: article.ap_id, }) } @@ -65,8 +65,8 @@ impl Object for DbEdit { ap_id: json.id, diff: json.content, article_id: article.id, - version: json.version, - previous_version: json.previous_version, + hash: json.version, + previous_version_id: json.previous_version, }; let edit = DbEdit::create(&form, &data.db_connection)?; Ok(edit) diff --git a/src/lib.rs b/src/lib.rs index de5495d..bf4d242 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ use crate::api::api_routes; use crate::database::instance::{DbInstance, DbInstanceForm}; -use crate::database::{FakeDatabase, MyData}; +use crate::database::MyData; use crate::error::MyResult; use crate::federation::routes::federation_routes; use crate::utils::generate_activity_id; @@ -28,10 +28,6 @@ mod utils; const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); pub async fn start(hostname: &str, database_url: &str) -> MyResult<()> { - let fake_db = Arc::new(FakeDatabase { - conflicts: Mutex::new(vec![]), - }); - let db_connection = Arc::new(Mutex::new(PgConnection::establish(database_url)?)); db_connection .lock() @@ -39,10 +35,7 @@ pub async fn start(hostname: &str, database_url: &str) -> MyResult<()> { .run_pending_migrations(MIGRATIONS) .unwrap(); - let data = MyData { - db_connection, - fake_db, - }; + let data = MyData { db_connection }; let config = FederationConfig::builder() .domain(hostname) .app_data(data) diff --git a/src/utils.rs b/src/utils.rs index 5c254f7..d579323 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use crate::database::edit::DbEdit; -use crate::database::edit::EditVersion; +use crate::database::version::EditVersion; use crate::error::MyResult; use anyhow::anyhow; use diffy::{apply, Patch}; @@ -31,7 +31,7 @@ pub fn generate_article_version(edits: &Vec, version: &EditVersion) -> M for e in edits { let patch = Patch::from_str(&e.diff)?; generated = apply(&generated, &patch)?; - if &e.version == version { + if &e.hash == version { return Ok(generated); } } diff --git a/tests/common.rs b/tests/common.rs index d6a2bbb..68aa86f 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,12 +1,14 @@ +use anyhow::anyhow; use fediwiki::api::{ - ApiConflict, CreateArticleData, EditArticleData, FollowInstance, GetArticleData, ResolveObject, + CreateArticleData, EditArticleData, FollowInstance, GetArticleData, ResolveObject, }; use fediwiki::database::article::ArticleView; +use fediwiki::database::conflict::ApiConflict; use fediwiki::database::instance::DbInstance; use fediwiki::error::MyResult; use fediwiki::start; use once_cell::sync::Lazy; -use reqwest::Client; +use reqwest::{Client, RequestBuilder, StatusCode}; use serde::de::Deserialize; use serde::ser::Serialize; use std::env::current_dir; @@ -142,7 +144,7 @@ pub async fn create_article(hostname: &str, title: String) -> MyResult MyResult> { - Ok(CLIENT + let req = CLIENT .patch(format!("http://{}/api/v1/article", hostname)) - .form(edit_form) - .send() - .await? - .json() - .await?) + .form(edit_form); + handle_json_res(req).await } pub async fn edit_article(hostname: &str, edit_form: &EditArticleData) -> MyResult { @@ -184,25 +183,34 @@ where T: for<'de> Deserialize<'de>, R: Serialize, { - let mut res = CLIENT.get(format!("http://{}/api/v1/{}", hostname, endpoint)); + let mut req = CLIENT.get(format!("http://{}/api/v1/{}", hostname, endpoint)); if let Some(query) = query { - res = res.query(&query); + req = req.query(&query); } - let alpha_instance: T = res.send().await?.json().await?; - Ok(alpha_instance) + handle_json_res(req).await } pub async fn post(hostname: &str, endpoint: &str, form: &T) -> MyResult where R: for<'de> Deserialize<'de>, { - Ok(CLIENT + let req = CLIENT .post(format!("http://{}/api/v1/{}", hostname, endpoint)) - .form(form) - .send() - .await? - .json() - .await?) + .form(form); + handle_json_res(req).await +} + +async fn handle_json_res(req: RequestBuilder) -> MyResult +where + T: for<'de> Deserialize<'de>, +{ + let res = req.send().await?; + if res.status() == StatusCode::OK { + Ok(res.json().await?) + } else { + let text = res.text().await?; + Err(anyhow!("Post API response {text}").into()) + } } pub async fn follow_instance(api_instance: &str, follow_instance: &str) -> MyResult<()> { diff --git a/tests/test.rs b/tests/test.rs index c93ff91..89dd363 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -7,12 +7,11 @@ use crate::common::{ get_query, post, TestData, TEST_ARTICLE_DEFAULT_TEXT, }; use common::get; -use fediwiki::api::{ - ApiConflict, EditArticleData, ForkArticleData, ResolveObject, SearchArticleData, -}; +use fediwiki::api::{EditArticleData, ForkArticleData, ResolveObject, SearchArticleData}; use fediwiki::database::article::{ArticleView, DbArticle}; use fediwiki::error::MyResult; +use fediwiki::database::conflict::ApiConflict; use fediwiki::database::instance::{DbInstance, InstanceView}; use pretty_assertions::{assert_eq, assert_ne}; use url::Url; @@ -42,7 +41,7 @@ async fn test_create_read_and_edit_article() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Lorem Ipsum 2".to_string(), - previous_version: get_res.latest_version, + previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; @@ -125,7 +124,7 @@ async fn test_synchronize_articles() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Lorem Ipsum 2\n".to_string(), - previous_version: create_res.latest_version, + previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; edit_article(&data.alpha.hostname, &edit_form).await?; @@ -179,7 +178,7 @@ async fn test_edit_local_article() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Lorem Ipsum 2".to_string(), - previous_version: get_res.latest_version, + previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; let edit_res = edit_article(&data.beta.hostname, &edit_form).await?; @@ -225,7 +224,7 @@ async fn test_edit_remote_article() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Lorem Ipsum 2".to_string(), - previous_version: get_res.latest_version, + previous_version_id: get_res.latest_version, resolve_conflict_id: None, }; let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; @@ -265,7 +264,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Lorem Ipsum\n".to_string(), - previous_version: create_res.latest_version.clone(), + previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; @@ -276,7 +275,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Ipsum Lorem\n".to_string(), - previous_version: create_res.latest_version, + previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; let edit_res = edit_article_with_conflict(&data.alpha.hostname, &edit_form) @@ -292,7 +291,7 @@ async fn test_local_edit_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Lorem Ipsum and Ipsum Lorem\n".to_string(), - previous_version: edit_res.previous_version, + previous_version_id: edit_res.previous_version_id, resolve_conflict_id: Some(edit_res.id), }; let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; @@ -333,7 +332,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "Lorem Ipsum\n".to_string(), - previous_version: create_res.latest_version.clone(), + previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; @@ -350,7 +349,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "aaaa\n".to_string(), - previous_version: create_res.latest_version, + previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; let edit_res = edit_article(&data.gamma.hostname, &edit_form).await?; @@ -367,8 +366,8 @@ async fn test_federated_edit_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "aaaa\n".to_string(), - previous_version: conflicts[0].previous_version.clone(), - resolve_conflict_id: Some(conflicts[0].id), + previous_version_id: conflicts[0].previous_version_id.clone(), + resolve_conflict_id: Some(conflicts[0].id.clone()), }; let edit_res = edit_article(&data.gamma.hostname, &edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text); @@ -395,7 +394,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "my\nexample\ntext\n".to_string(), - previous_version: create_res.latest_version.clone(), + previous_version_id: create_res.latest_version.clone(), resolve_conflict_id: None, }; let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; @@ -406,7 +405,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> { let edit_form = EditArticleData { article_id: create_res.article.id, new_text: "some\nexample\narticle\n".to_string(), - previous_version: create_res.latest_version, + previous_version_id: create_res.latest_version, resolve_conflict_id: None, }; let edit_res = edit_article(&data.alpha.hostname, &edit_form).await?; @@ -448,7 +447,7 @@ async fn test_fork_article() -> MyResult<()> { assert_eq!(resolved_article.text, forked_article.text); assert_eq!(resolve_res.edits.len(), fork_res.edits.len()); assert_eq!(resolve_res.edits[0].diff, fork_res.edits[0].diff); - assert_eq!(resolve_res.edits[0].version, fork_res.edits[0].version); + assert_eq!(resolve_res.edits[0].hash, fork_res.edits[0].hash); assert_ne!(resolve_res.edits[0].id, fork_res.edits[0].id); assert_eq!(resolve_res.latest_version, fork_res.latest_version); assert_ne!(resolved_article.ap_id, forked_article.ap_id);