dont apply edit to already updated text

This commit is contained in:
Felix Ableitner 2023-11-24 16:09:17 +01:00
parent d224016327
commit 1539795f03
5 changed files with 50 additions and 17 deletions

View File

@ -3,7 +3,7 @@ use crate::error::MyResult;
use crate::federation::activities::create_article::CreateArticle; use crate::federation::activities::create_article::CreateArticle;
use crate::federation::activities::update_article::UpdateArticle; use crate::federation::activities::update_article::UpdateArticle;
use crate::federation::objects::article::DbArticle; use crate::federation::objects::article::DbArticle;
use crate::federation::objects::edit::DbEdit; use crate::federation::objects::edit::{DbEdit, EditVersion};
use crate::federation::objects::instance::DbInstance; use crate::federation::objects::instance::DbInstance;
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::fetch::object_id::ObjectId;
@ -40,17 +40,17 @@ async fn create_article(
Form(create_article): Form<CreateArticleData>, Form(create_article): Form<CreateArticleData>,
) -> MyResult<Json<DbArticle>> { ) -> MyResult<Json<DbArticle>> {
let local_instance_id = data.local_instance().ap_id; let local_instance_id = data.local_instance().ap_id;
let ap_id = Url::parse(&format!( let ap_id = ObjectId::parse(&format!(
"http://{}:{}/article/{}", "http://{}:{}/article/{}",
local_instance_id.inner().domain().unwrap(), local_instance_id.inner().domain().unwrap(),
local_instance_id.inner().port().unwrap(), local_instance_id.inner().port().unwrap(),
create_article.title create_article.title
))? ))?;
.into();
let article = DbArticle { let article = DbArticle {
title: create_article.title, title: create_article.title,
text: String::new(), text: String::new(),
ap_id, ap_id,
latest_version: EditVersion::default(),
edits: vec![], edits: vec![],
instance: local_instance_id, instance: local_instance_id,
local: true, local: true,
@ -87,6 +87,7 @@ async fn edit_article(
let mut lock = data.articles.lock().unwrap(); let mut lock = data.articles.lock().unwrap();
let article = lock.get_mut(edit_article.ap_id.inner()).unwrap(); let article = lock.get_mut(edit_article.ap_id.inner()).unwrap();
article.text = edit_article.new_text; article.text = edit_article.new_text;
article.latest_version = edit.version.clone();
article.edits.push(edit.clone()); article.edits.push(edit.clone());
article.clone() article.clone()
}; };

View File

@ -2,6 +2,7 @@ use crate::database::{Database, DatabaseHandle};
use crate::error::Error; use crate::error::Error;
use crate::federation::objects::instance::DbInstance; use crate::federation::objects::instance::DbInstance;
use activitypub_federation::config::FederationConfig; use activitypub_federation::config::FederationConfig;
use activitypub_federation::fetch::collection_id::CollectionId;
use activitypub_federation::http_signatures::generate_actor_keypair; use activitypub_federation::http_signatures::generate_actor_keypair;
use chrono::Local; use chrono::Local;
use std::collections::HashMap; use std::collections::HashMap;
@ -14,7 +15,7 @@ pub mod routes;
pub async fn federation_config(hostname: &str) -> Result<FederationConfig<DatabaseHandle>, Error> { pub async fn federation_config(hostname: &str) -> Result<FederationConfig<DatabaseHandle>, Error> {
let ap_id = Url::parse(&format!("http://{}", hostname))?.into(); let ap_id = Url::parse(&format!("http://{}", hostname))?.into();
let articles_id = Url::parse(&format!("http://{}/all_articles", hostname))?.into(); let articles_id = CollectionId::parse(&format!("http://{}/all_articles", hostname))?;
let inbox = Url::parse(&format!("http://{}/inbox", hostname))?; let inbox = Url::parse(&format!("http://{}/inbox", hostname))?;
let keypair = generate_actor_keypair()?; let keypair = generate_actor_keypair()?;
let local_instance = DbInstance { let local_instance = DbInstance {

View File

@ -1,5 +1,5 @@
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::objects::edit::DbEdit; use crate::federation::objects::edit::{DbEdit, EditVersion};
use crate::federation::objects::edits_collection::DbEditCollection; use crate::federation::objects::edits_collection::DbEditCollection;
use crate::federation::objects::instance::DbInstance; use crate::federation::objects::instance::DbInstance;
use crate::{database::DatabaseHandle, error::Error}; use crate::{database::DatabaseHandle, error::Error};
@ -23,14 +23,13 @@ pub struct DbArticle {
pub instance: ObjectId<DbInstance>, pub instance: ObjectId<DbInstance>,
/// List of all edits which make up this article, oldest first. /// List of all edits which make up this article, oldest first.
pub edits: Vec<DbEdit>, pub edits: Vec<DbEdit>,
pub latest_version: EditVersion,
pub local: bool, pub local: bool,
} }
impl DbArticle { impl DbArticle {
fn edits_id(&self) -> MyResult<CollectionId<DbEditCollection>> { fn edits_id(&self) -> MyResult<CollectionId<DbEditCollection>> {
Ok(CollectionId::parse(&format!("{}/edits", self.ap_id)) Ok(CollectionId::parse(&format!("{}/edits", self.ap_id))?)
.unwrap()
.into())
} }
} }
@ -44,6 +43,7 @@ pub struct ApubArticle {
#[serde(deserialize_with = "deserialize_one_or_many")] #[serde(deserialize_with = "deserialize_one_or_many")]
pub(crate) to: Vec<Url>, pub(crate) to: Vec<Url>,
edits: CollectionId<DbEditCollection>, edits: CollectionId<DbEditCollection>,
latest_version: EditVersion,
content: String, content: String,
name: String, name: String,
} }
@ -75,6 +75,7 @@ impl Object for DbArticle {
attributed_to: self.instance.clone(), attributed_to: self.instance.clone(),
to: vec![public(), instance.followers_url()?], to: vec![public(), instance.followers_url()?],
edits: self.edits_id()?, edits: self.edits_id()?,
latest_version: self.latest_version,
content: self.text, content: self.text,
name: self.title, name: self.title,
}) })
@ -97,6 +98,7 @@ impl Object for DbArticle {
instance: json.attributed_to, instance: json.attributed_to,
// TODO: shouldnt overwrite existing edits // TODO: shouldnt overwrite existing edits
edits: vec![], edits: vec![],
latest_version: json.latest_version,
local: false, local: false,
}; };
@ -105,7 +107,7 @@ impl Object for DbArticle {
lock.insert(article.ap_id.inner().clone(), article.clone()); lock.insert(article.ap_id.inner().clone(), article.clone());
} }
json.edits.dereference(&article, &data).await?; json.edits.dereference(&article, data).await?;
Ok(article) Ok(article)
} }

View File

@ -10,12 +10,24 @@ use sha2::Digest;
use sha2::Sha224; use sha2::Sha224;
use url::Url; use url::Url;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct EditVersion(String);
impl Default for EditVersion {
fn default() -> Self {
let sha224 = Sha224::new();
let hash = format!("{:X}", sha224.finalize());
EditVersion(hash)
}
}
/// Represents a single change to the article. /// Represents a single change to the article.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DbEdit { pub struct DbEdit {
pub id: ObjectId<DbEdit>, pub id: ObjectId<DbEdit>,
pub diff: String, pub diff: String,
pub article_id: ObjectId<DbArticle>, pub article_id: ObjectId<DbArticle>,
pub version: EditVersion,
pub local: bool, pub local: bool,
} }
@ -30,6 +42,7 @@ impl DbEdit {
id: edit_id, id: edit_id,
diff: diff.to_string(), diff: diff.to_string(),
article_id: original_article.ap_id.clone(), article_id: original_article.ap_id.clone(),
version: EditVersion(hash),
local: true, local: true,
}) })
} }
@ -46,7 +59,8 @@ pub struct ApubEdit {
#[serde(rename = "type")] #[serde(rename = "type")]
kind: EditType, kind: EditType,
id: ObjectId<DbEdit>, id: ObjectId<DbEdit>,
pub(crate) content: String, pub content: String,
pub version: EditVersion,
pub object: ObjectId<DbArticle>, pub object: ObjectId<DbArticle>,
} }
@ -68,6 +82,7 @@ impl Object for DbEdit {
kind: EditType::Edit, kind: EditType::Edit,
id: self.id, id: self.id,
content: self.diff, content: self.diff,
version: self.version,
object: self.article_id, object: self.article_id,
}) })
} }
@ -85,15 +100,19 @@ impl Object for DbEdit {
id: json.id, id: json.id,
diff: json.content, diff: json.content,
article_id: json.object, article_id: json.object,
version: json.version,
local: false, local: false,
}; };
let mut lock = data.articles.lock().unwrap(); let mut lock = data.articles.lock().unwrap();
let article = lock.get_mut(edit.article_id.inner()).unwrap(); let article = lock.get_mut(edit.article_id.inner()).unwrap();
article.edits.push(edit.clone()); article.edits.push(edit.clone());
let patch = Patch::from_str(&edit.diff)?; let patch = Patch::from_str(&edit.diff)?;
// TODO: this will give wrong result if new article text is federated, and then also new // Dont apply the edit if we already fetched an update Article version.
// edit is applied. probably need to keep track of versions // TODO: this assumes that we always receive edits in the correct order, probably need to
// include the parent for each edit
if article.latest_version != edit.version {
article.text = apply(&article.text, &patch)?; article.text = apply(&article.text, &patch)?;
}
Ok(edit) Ok(edit)
} }

View File

@ -81,6 +81,13 @@ async fn test_synchronize_articles() -> MyResult<()> {
assert_eq!(0, create_res.edits.len()); assert_eq!(0, create_res.edits.len());
assert!(create_res.local); assert!(create_res.local);
// edit the article
let edit_form = EditArticleData {
ap_id: create_res.ap_id.clone(),
new_text: "Lorem Ipsum 2".to_string(),
};
edit_article(data.hostname_alpha, &title, &edit_form).await?;
// article is not yet on beta // article is not yet on beta
let get_res = get_article(data.hostname_beta, &create_res.title).await; let get_res = get_article(data.hostname_beta, &create_res.title).await;
assert!(get_res.is_err()); assert!(get_res.is_err());
@ -96,8 +103,8 @@ async fn test_synchronize_articles() -> MyResult<()> {
let get_res = get_article(data.hostname_beta, &create_res.title).await?; let get_res = get_article(data.hostname_beta, &create_res.title).await?;
assert_eq!(create_res.ap_id, get_res.ap_id); assert_eq!(create_res.ap_id, get_res.ap_id);
assert_eq!(title, get_res.title); assert_eq!(title, get_res.title);
assert_eq!(0, get_res.edits.len()); assert_eq!(1, get_res.edits.len());
assert!(get_res.text.is_empty()); assert_eq!(edit_form.new_text, get_res.text);
assert!(!get_res.local); assert!(!get_res.local);
data.stop() data.stop()
@ -231,7 +238,8 @@ async fn test_edit_conflict() -> MyResult<()> {
.to_string() .to_string()
.starts_with(&edit_res.ap_id.to_string())); .starts_with(&edit_res.ap_id.to_string()));
// gamma also edits, as its not the latest version there is a conflict // gamma also edits, as its not the latest version there is a conflict. local version should
// not be updated with this conflicting version, instead user needs to handle the conflict
let edit_form = EditArticleData { let edit_form = EditArticleData {
ap_id: create_res.ap_id, ap_id: create_res.ap_id,
new_text: "aaaa".to_string(), new_text: "aaaa".to_string(),
@ -241,5 +249,7 @@ async fn test_edit_conflict() -> MyResult<()> {
assert_eq!(0, edit_res.edits.len()); assert_eq!(0, edit_res.edits.len());
assert!(!edit_res.local); assert!(!edit_res.local);
// TODO: need to federate the conflict as `Reject` and then resolve it
data.stop() data.stop()
} }