allow forking remote article to local instance, add search

This commit is contained in:
Felix Ableitner 2023-11-28 15:11:05 +01:00
parent 782ddd30f3
commit d0e163ed61
6 changed files with 170 additions and 9 deletions

View File

@ -25,11 +25,13 @@ pub fn api_routes() -> Router {
"/article", "/article",
get(get_article).post(create_article).patch(edit_article), get(get_article).post(create_article).patch(edit_article),
) )
.route("/article/fork", post(fork_article))
.route("/edit_conflicts", get(edit_conflicts)) .route("/edit_conflicts", get(edit_conflicts))
.route("/resolve_instance", get(resolve_instance)) .route("/resolve_instance", get(resolve_instance))
.route("/resolve_article", get(resolve_article)) .route("/resolve_article", get(resolve_article))
.route("/instance", get(get_local_instance)) .route("/instance", get(get_local_instance))
.route("/instance/follow", post(follow_instance)) .route("/instance/follow", post(follow_instance))
.route("/search", get(search_article))
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -42,6 +44,16 @@ async fn create_article(
data: Data<DatabaseHandle>, data: Data<DatabaseHandle>,
Form(create_article): Form<CreateArticleData>, Form(create_article): Form<CreateArticleData>,
) -> MyResult<Json<DbArticle>> { ) -> MyResult<Json<DbArticle>> {
{
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 local_instance_id = data.local_instance().ap_id;
let ap_id = ObjectId::parse(&format!( let ap_id = ObjectId::parse(&format!(
"http://{}:{}/article/{}", "http://{}:{}/article/{}",
@ -204,3 +216,77 @@ async fn edit_conflicts(data: Data<DatabaseHandle>) -> MyResult<Json<Vec<ApiConf
.collect(); .collect();
Ok(Json(conflicts)) Ok(Json(conflicts))
} }
#[derive(Deserialize, Serialize, Clone)]
pub struct SearchArticleData {
pub title: String,
}
#[debug_handler]
async fn search_article(
Query(query): Query<SearchArticleData>,
data: Data<DatabaseHandle>,
) -> MyResult<Json<Vec<DbArticle>>> {
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<DbArticle>,
}
#[debug_handler]
async fn fork_article(
data: Data<DatabaseHandle>,
Form(fork_form): Form<ForkArticleData>,
) -> MyResult<Json<DbArticle>> {
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))
}

View File

@ -15,7 +15,7 @@ use activitypub_federation::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DbArticle { pub struct DbArticle {
pub title: String, pub title: String,
pub text: String, pub text: String,
@ -91,7 +91,7 @@ impl Object for DbArticle {
} }
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> { async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
let article = DbArticle { let mut article = DbArticle {
title: json.name, title: json.name,
text: json.content, text: json.content,
ap_id: json.id, ap_id: json.id,
@ -107,7 +107,10 @@ 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?; 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) Ok(article)
} }

View File

@ -23,7 +23,7 @@ impl Default for EditVersion {
} }
/// Represents a single change to the article. /// Represents a single change to the article.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DbEdit { pub struct DbEdit {
pub id: ObjectId<DbEdit>, pub id: ObjectId<DbEdit>,
pub diff: String, pub diff: String,

View File

@ -23,7 +23,7 @@ pub struct ApubEditCollection {
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DbEditCollection(Vec<DbEdit>); pub struct DbEditCollection(pub Vec<DbEdit>);
#[async_trait::async_trait] #[async_trait::async_trait]
impl Collection for DbEditCollection { impl Collection for DbEditCollection {

View File

@ -140,7 +140,7 @@ where
Ok(alpha_instance) Ok(alpha_instance)
} }
async fn post<T: Serialize, R>(hostname: &str, endpoint: &str, form: &T) -> MyResult<R> pub async fn post<T: Serialize, R>(hostname: &str, endpoint: &str, form: &T) -> MyResult<R>
where where
R: for<'de> Deserialize<'de>, R: for<'de> Deserialize<'de>,
{ {

View File

@ -4,16 +4,17 @@ mod common;
use crate::common::{ use crate::common::{
create_article, edit_article, edit_article_with_conflict, follow_instance, get_article, 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 common::get;
use fediwiki::api::{ApiConflict, EditArticleData, ResolveObject}; use fediwiki::api::{
ApiConflict, EditArticleData, ForkArticleData, ResolveObject, SearchArticleData,
};
use fediwiki::error::MyResult; use fediwiki::error::MyResult;
use fediwiki::federation::objects::article::DbArticle; use fediwiki::federation::objects::article::DbArticle;
use fediwiki::federation::objects::edit::ApubEdit; use fediwiki::federation::objects::edit::ApubEdit;
use fediwiki::federation::objects::instance::DbInstance; use fediwiki::federation::objects::instance::DbInstance;
use serial_test::serial; use serial_test::serial;
use url::Url; use url::Url;
#[tokio::test] #[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!(edit_form.new_text, edit_res.text);
assert_eq!(2, edit_res.edits.len()); assert_eq!(2, edit_res.edits.len());
let search_form = SearchArticleData {
title: title.clone(),
};
let search_res: Vec<DbArticle> =
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() data.stop()
} }
@ -378,3 +404,49 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
data.stop() 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::<DbArticle, _>(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<DbArticle> =
get_query(data.hostname_beta, "search", Some(search_form)).await?;
assert_eq!(2, search_res.len());
data.stop()
}