From 22abbebd41d586298c62bb6a45efa7a96d998049 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Mon, 27 Apr 2020 12:57:00 -0400 Subject: [PATCH] Lots of additions to federation. - Added a shared inbox. - Added federated comments, comment updates, and tests. - Abstracted ap object sends into a common trait. --- docker/federation-test/run-tests.sh | 3 +- server/src/api/comment.rs | 17 +- server/src/api/mod.rs | 6 +- server/src/api/post.rs | 4 +- server/src/apub/activities.rs | 62 +----- server/src/apub/comment.rs | 139 +++++++++++++ server/src/apub/community.rs | 26 +++ server/src/apub/mod.rs | 56 ++++-- server/src/apub/post.rs | 48 +++++ server/src/apub/shared_inbox.rs | 242 +++++++++++++++++++++- server/src/apub/user_inbox.rs | 65 ------ server/src/db/comment.rs | 8 + server/src/db/comment_view.rs | 1 + server/src/db/moderator.rs | 1 + server/src/db/user_mention.rs | 1 + server/src/routes/federation.rs | 4 +- ui/jest.config.js | 1 + ui/src/api_tests/api.spec.ts | 302 ++++++++++++++++++++++++++-- 18 files changed, 811 insertions(+), 175 deletions(-) create mode 100644 server/src/apub/comment.rs diff --git a/docker/federation-test/run-tests.sh b/docker/federation-test/run-tests.sh index 4206f060f..9d8a7e58f 100755 --- a/docker/federation-test/run-tests.sh +++ b/docker/federation-test/run-tests.sh @@ -12,7 +12,8 @@ sudo docker-compose --file ../federation/docker-compose.yml --project-directory pushd ../../ui yarn echo "Waiting for Lemmy to start..." -while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8540/api/v1/site')" != "200" ]]; do sleep 5; done +while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8540/api/v1/site')" != "200" ]]; do sleep 1; done +while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8550/api/v1/site')" != "200" ]]; do sleep 1; done yarn api-test || true popd diff --git a/server/src/api/comment.rs b/server/src/api/comment.rs index eb67d8f20..fddb42abe 100644 --- a/server/src/api/comment.rs +++ b/server/src/api/comment.rs @@ -87,7 +87,8 @@ impl Perform for Oper { } // Check for a site ban - if UserView::read(&conn, user_id)?.banned { + let user = User_::read(&conn, user_id)?; + if user.banned { return Err(APIError::err("site_ban").into()); } @@ -101,6 +102,7 @@ impl Perform for Oper { removed: None, deleted: None, read: None, + published: None, updated: None, ap_id: "changeme".into(), local: true, @@ -111,11 +113,13 @@ impl Perform for Oper { Err(_e) => return Err(APIError::err("couldnt_create_comment").into()), }; - match Comment::update_ap_id(&conn, inserted_comment.id) { + let updated_comment = match Comment::update_ap_id(&conn, inserted_comment.id) { Ok(comment) => comment, Err(_e) => return Err(APIError::err("couldnt_create_comment").into()), }; + updated_comment.send_create(&user, &conn)?; + let mut recipient_ids = Vec::new(); // Scan the comment for user mentions, add those rows @@ -273,6 +277,8 @@ impl Perform for Oper { let conn = pool.get()?; + let user = User_::read(&conn, user_id)?; + let orig_comment = CommentView::read(&conn, data.edit_id, None)?; // You are allowed to mark the comment as read even if you're banned. @@ -297,7 +303,7 @@ impl Perform for Oper { } // Check for a site ban - if UserView::read(&conn, user_id)?.banned { + if user.banned { return Err(APIError::err("site_ban").into()); } } @@ -314,6 +320,7 @@ impl Perform for Oper { removed: data.removed.to_owned(), deleted: data.deleted.to_owned(), read: data.read.to_owned(), + published: None, updated: if data.read.is_some() { orig_comment.updated } else { @@ -323,11 +330,13 @@ impl Perform for Oper { local: read_comment.local, }; - let _updated_comment = match Comment::update(&conn, data.edit_id, &comment_form) { + let updated_comment = match Comment::update(&conn, data.edit_id, &comment_form) { Ok(comment) => comment, Err(_e) => return Err(APIError::err("couldnt_update_comment").into()), }; + updated_comment.send_update(&user, &conn)?; + let mut recipient_ids = Vec::new(); // Scan the comment for user mentions, add those rows diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 0595f2a40..70ff2bfec 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -23,19 +23,17 @@ use crate::{ }; use crate::apub::{ - activities::{send_post_create, send_post_update}, fetcher::search_by_apub_id, signatures::generate_actor_keypair, - {make_apub_endpoint, ActorType, EndpointType}, + {make_apub_endpoint, ActorType, ApubObjectType, EndpointType}, }; use crate::settings::Settings; -use crate::websocket::UserOperation; use crate::websocket::{ server::{ JoinCommunityRoom, JoinPostRoom, JoinUserRoom, SendAllMessage, SendComment, SendCommunityRoomMessage, SendPost, SendUserRoomMessage, }, - WebsocketInfo, + UserOperation, WebsocketInfo, }; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::PgConnection; diff --git a/server/src/api/post.rs b/server/src/api/post.rs index 89f1dd1d3..5be227d8d 100644 --- a/server/src/api/post.rs +++ b/server/src/api/post.rs @@ -160,7 +160,7 @@ impl Perform for Oper { Err(_e) => return Err(APIError::err("couldnt_create_post").into()), }; - send_post_create(&updated_post, &user, &conn)?; + updated_post.send_create(&user, &conn)?; // They like their own post by default let like_form = PostLikeForm { @@ -531,7 +531,7 @@ impl Perform for Oper { ModStickyPost::create(&conn, &form)?; } - send_post_update(&updated_post, &user, &conn)?; + updated_post.send_update(&user, &conn)?; let post_view = PostView::read(&conn, data.edit_id, Some(user_id))?; diff --git a/server/src/apub/activities.rs b/server/src/apub/activities.rs index cb98e9734..517fd2481 100644 --- a/server/src/apub/activities.rs +++ b/server/src/apub/activities.rs @@ -1,6 +1,6 @@ use super::*; -fn populate_object_props( +pub fn populate_object_props( props: &mut ObjectProperties, addressed_to: &str, object_id: &str, @@ -47,63 +47,3 @@ where } Ok(()) } - -/// For a given community, returns the inboxes of all followers. -fn get_follower_inboxes(conn: &PgConnection, community: &Community) -> Result, Error> { - Ok( - CommunityFollowerView::for_community(conn, community.id)? - .into_iter() - .filter(|c| !c.user_local) - // TODO eventually this will have to use the inbox or shared_inbox column, meaning that view - // will have to change - .map(|c| format!("{}/inbox", c.user_actor_id.to_owned())) - .unique() - .collect(), - ) -} - -/// Send out information about a newly created post, to the followers of the community. -pub fn send_post_create(post: &Post, creator: &User_, conn: &PgConnection) -> Result<(), Error> { - let page = post.to_apub(conn)?; - let community = Community::read(conn, post.community_id)?; - let mut create = Create::new(); - populate_object_props( - &mut create.object_props, - &community.get_followers_url(), - &post.ap_id, - )?; - create - .create_props - .set_actor_xsd_any_uri(creator.actor_id.to_owned())? - .set_object_base_box(page)?; - send_activity( - &create, - &creator.private_key.as_ref().unwrap(), - &creator.actor_id, - get_follower_inboxes(conn, &community)?, - )?; - Ok(()) -} - -/// Send out information about an edited post, to the followers of the community. -pub fn send_post_update(post: &Post, creator: &User_, conn: &PgConnection) -> Result<(), Error> { - let page = post.to_apub(conn)?; - let community = Community::read(conn, post.community_id)?; - let mut update = Update::new(); - populate_object_props( - &mut update.object_props, - &community.get_followers_url(), - &post.ap_id, - )?; - update - .update_props - .set_actor_xsd_any_uri(creator.actor_id.to_owned())? - .set_object_base_box(page)?; - send_activity( - &update, - &creator.private_key.as_ref().unwrap(), - &creator.actor_id, - get_follower_inboxes(conn, &community)?, - )?; - Ok(()) -} diff --git a/server/src/apub/comment.rs b/server/src/apub/comment.rs new file mode 100644 index 000000000..3b7c0dfe0 --- /dev/null +++ b/server/src/apub/comment.rs @@ -0,0 +1,139 @@ +use super::*; + +impl ToApub for Comment { + type Response = Note; + + fn to_apub(&self, conn: &PgConnection) -> Result { + let mut comment = Note::default(); + let oprops: &mut ObjectProperties = comment.as_mut(); + let creator = User_::read(&conn, self.creator_id)?; + let post = Post::read(&conn, self.post_id)?; + let community = Community::read(&conn, post.community_id)?; + + // Add a vector containing some important info to the "in_reply_to" field + // [post_ap_id, Option(parent_comment_ap_id)] + let mut in_reply_to_vec = vec![post.ap_id]; + + if let Some(parent_id) = self.parent_id { + let parent_comment = Comment::read(&conn, parent_id)?; + in_reply_to_vec.push(parent_comment.ap_id); + } + + oprops + // Not needed when the Post is embedded in a collection (like for community outbox) + .set_context_xsd_any_uri(context())? + .set_id(self.ap_id.to_owned())? + // Use summary field to be consistent with mastodon content warning. + // https://mastodon.xyz/@Louisa/103987265222901387.json + // .set_summary_xsd_string(self.name.to_owned())? + .set_published(convert_datetime(self.published))? + .set_to_xsd_any_uri(community.actor_id)? + .set_many_in_reply_to_xsd_any_uris(in_reply_to_vec)? + .set_content_xsd_string(self.content.to_owned())? + .set_attributed_to_xsd_any_uri(creator.actor_id)?; + + if let Some(u) = self.updated { + oprops.set_updated(convert_datetime(u))?; + } + + Ok(comment) + } +} + +impl FromApub for CommentForm { + type ApubType = Note; + + /// Parse an ActivityPub note received from another instance into a Lemmy comment + fn from_apub(note: &Note, conn: &PgConnection) -> Result { + let oprops = ¬e.object_props; + let creator_actor_id = &oprops.get_attributed_to_xsd_any_uri().unwrap().to_string(); + let creator = get_or_fetch_and_upsert_remote_user(&creator_actor_id, &conn)?; + + let mut in_reply_tos = oprops.get_many_in_reply_to_xsd_any_uris().unwrap(); + let post_ap_id = in_reply_tos.next().unwrap().to_string(); + + // The 2nd item, if it exists, is the parent comment apub_id + let parent_id: Option = match in_reply_tos.next() { + Some(parent_comment_uri) => { + let parent_comment_uri_str = &parent_comment_uri.to_string(); + let parent_comment = Comment::read_from_apub_id(&conn, &parent_comment_uri_str)?; + + Some(parent_comment.id) + } + None => None, + }; + + let post = Post::read_from_apub_id(&conn, &post_ap_id)?; + + Ok(CommentForm { + creator_id: creator.id, + post_id: post.id, + parent_id, + content: oprops + .get_content_xsd_string() + .map(|c| c.to_string()) + .unwrap(), + removed: None, + read: None, + published: oprops + .get_published() + .map(|u| u.as_ref().to_owned().naive_local()), + updated: oprops + .get_updated() + .map(|u| u.as_ref().to_owned().naive_local()), + deleted: None, + ap_id: oprops.get_id().unwrap().to_string(), + local: false, + }) + } +} + +impl ApubObjectType for Comment { + /// Send out information about a newly created comment, to the followers of the community. + fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> { + let note = self.to_apub(conn)?; + let post = Post::read(&conn, self.post_id)?; + let community = Community::read(conn, post.community_id)?; + let mut create = Create::new(); + populate_object_props( + &mut create.object_props, + &community.get_followers_url(), + &self.ap_id, + )?; + create + .create_props + .set_actor_xsd_any_uri(creator.actor_id.to_owned())? + .set_object_base_box(note)?; + send_activity( + &create, + &creator.private_key.as_ref().unwrap(), + &creator.actor_id, + community.get_follower_inboxes(&conn)?, + )?; + Ok(()) + } + + /// Send out information about an edited post, to the followers of the community. + fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> { + let note = self.to_apub(&conn)?; + let post = Post::read(&conn, self.post_id)?; + let community = Community::read(&conn, post.community_id)?; + let mut update = Update::new(); + populate_object_props( + &mut update.object_props, + &community.get_followers_url(), + &self.ap_id, + )?; + update + .update_props + .set_actor_xsd_any_uri(creator.actor_id.to_owned())? + .set_object_base_box(note)?; + send_activity( + &update, + &creator.private_key.as_ref().unwrap(), + &creator.actor_id, + community.get_follower_inboxes(&conn)?, + )?; + Ok(()) + } +} diff --git a/server/src/apub/community.rs b/server/src/apub/community.rs index bc984b250..d66bbc019 100644 --- a/server/src/apub/community.rs +++ b/server/src/apub/community.rs @@ -89,6 +89,32 @@ impl ActorType for Community { )?; Ok(()) } + + /// For a given community, returns the inboxes of all followers. + fn get_follower_inboxes(&self, conn: &PgConnection) -> Result, Error> { + debug!("got here."); + + Ok( + CommunityFollowerView::for_community(conn, self.id)? + .into_iter() + // TODO eventually this will have to use the inbox or shared_inbox column, meaning that view + // will have to change + .map(|c| { + // If the user is local, but the community isn't, get the community shared inbox + // and vice versa + if c.user_local && !c.community_local { + get_shared_inbox(&c.community_actor_id) + } else if !c.user_local && c.community_local { + get_shared_inbox(&c.user_actor_id) + } else { + "".to_string() + } + }) + .filter(|s| !s.is_empty()) + .unique() + .collect(), + ) + } } impl FromApub for CommunityForm { diff --git a/server/src/apub/mod.rs b/server/src/apub/mod.rs index 5c5852991..a861156f5 100644 --- a/server/src/apub/mod.rs +++ b/server/src/apub/mod.rs @@ -1,4 +1,5 @@ pub mod activities; +pub mod comment; pub mod community; pub mod community_inbox; pub mod fetcher; @@ -15,7 +16,11 @@ use activitystreams::{ context, endpoint::EndpointProperties, ext::{Ext, Extensible, Extension}, - object::{properties::ObjectProperties, Page}, + object::{ + kind::{NoteType, PageType}, + properties::ObjectProperties, + Note, Page, + }, public, BaseBox, }; use actix_web::body::Body; @@ -38,7 +43,11 @@ use std::collections::BTreeMap; use std::time::Duration; use url::Url; +use crate::api::comment::CommentResponse; +use crate::api::post::PostResponse; use crate::api::site::SearchResponse; +use crate::db::comment::{Comment, CommentForm}; +use crate::db::comment_view::CommentView; use crate::db::community::{Community, CommunityFollower, CommunityFollowerForm, CommunityForm}; use crate::db::community_view::{CommunityFollowerView, CommunityView}; use crate::db::post::{Post, PostForm}; @@ -48,9 +57,13 @@ use crate::db::user_view::UserView; use crate::db::{Crud, Followable, SearchType}; use crate::routes::nodeinfo::{NodeInfo, NodeInfoWellKnown}; use crate::routes::{ChatServerParam, DbPoolParam}; +use crate::websocket::{ + server::{SendComment, SendPost}, + UserOperation, +}; use crate::{convert_datetime, naive_now, Settings}; -use activities::send_activity; +use activities::{populate_object_props, send_activity}; use fetcher::{get_or_fetch_and_upsert_remote_community, get_or_fetch_and_upsert_remote_user}; use signatures::verify; use signatures::{sign, PublicKey, PublicKeyExtension}; @@ -142,6 +155,25 @@ pub trait FromApub { Self: Sized; } +pub trait ApubObjectType { + fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>; + fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>; +} + +pub fn get_shared_inbox(actor_id: &str) -> String { + let url = Url::parse(actor_id).unwrap(); + format!( + "{}://{}{}/inbox", + &url.scheme(), + &url.host_str().unwrap(), + if let Some(port) = url.port() { + format!(":{}", port) + } else { + "".to_string() + }, + ) +} + pub trait ActorType { fn actor_id(&self) -> String; @@ -159,24 +191,20 @@ pub trait ActorType { Ok(()) } + // TODO default because there is no user following yet. + #[allow(unused_variables)] + /// For a given community, returns the inboxes of all followers. + fn get_follower_inboxes(&self, conn: &PgConnection) -> Result, Error> { + Ok(vec![]) + } + // TODO move these to the db rows fn get_inbox_url(&self) -> String { format!("{}/inbox", &self.actor_id()) } fn get_shared_inbox_url(&self) -> String { - let url = Url::parse(&self.actor_id()).unwrap(); - let url_str = format!( - "{}://{}{}/inbox", - &url.scheme(), - &url.host_str().unwrap(), - if let Some(port) = url.port() { - format!(":{}", port) - } else { - "".to_string() - }, - ); - format!("{}/inbox", &url_str) + get_shared_inbox(&self.actor_id()) } fn get_outbox_url(&self) -> String { diff --git a/server/src/apub/post.rs b/server/src/apub/post.rs index 51ba861ef..0a054431e 100644 --- a/server/src/apub/post.rs +++ b/server/src/apub/post.rs @@ -92,3 +92,51 @@ impl FromApub for PostForm { }) } } + +impl ApubObjectType for Post { + /// Send out information about a newly created post, to the followers of the community. + fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> { + let page = self.to_apub(conn)?; + let community = Community::read(conn, self.community_id)?; + let mut create = Create::new(); + populate_object_props( + &mut create.object_props, + &community.get_followers_url(), + &self.ap_id, + )?; + create + .create_props + .set_actor_xsd_any_uri(creator.actor_id.to_owned())? + .set_object_base_box(page)?; + send_activity( + &create, + &creator.private_key.as_ref().unwrap(), + &creator.actor_id, + community.get_follower_inboxes(&conn)?, + )?; + Ok(()) + } + + /// Send out information about an edited post, to the followers of the community. + fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> { + let page = self.to_apub(conn)?; + let community = Community::read(conn, self.community_id)?; + let mut update = Update::new(); + populate_object_props( + &mut update.object_props, + &community.get_followers_url(), + &self.ap_id, + )?; + update + .update_props + .set_actor_xsd_any_uri(creator.actor_id.to_owned())? + .set_object_base_box(page)?; + send_activity( + &update, + &creator.private_key.as_ref().unwrap(), + &creator.actor_id, + community.get_follower_inboxes(&conn)?, + )?; + Ok(()) + } +} diff --git a/server/src/apub/shared_inbox.rs b/server/src/apub/shared_inbox.rs index 35ba3908a..f0cfc9900 100644 --- a/server/src/apub/shared_inbox.rs +++ b/server/src/apub/shared_inbox.rs @@ -1 +1,241 @@ -// use super::*; +use super::*; + +#[serde(untagged)] +#[derive(Serialize, Deserialize, Debug)] +pub enum SharedAcceptedObjects { + Create(Create), + Update(Update), +} + +/// Handler for all incoming activities to user inboxes. +pub async fn shared_inbox( + request: HttpRequest, + input: web::Json, + db: DbPoolParam, + chat_server: ChatServerParam, +) -> Result { + // TODO: would be nice if we could do the signature check here, but we cant access the actor property + let input = input.into_inner(); + let conn = &db.get().unwrap(); + + let json = serde_json::to_string(&input)?; + debug!("Shared inbox received activity: {:?}", &json); + + match input { + SharedAcceptedObjects::Create(c) => handle_create(&c, &request, &conn, chat_server), + SharedAcceptedObjects::Update(u) => handle_update(&u, &request, &conn, chat_server), + } +} + +/// Handle create activities and insert them in the database. +fn handle_create( + create: &Create, + request: &HttpRequest, + conn: &PgConnection, + chat_server: ChatServerParam, +) -> Result { + let base_box = create.create_props.get_object_base_box().unwrap(); + + if base_box.is_kind(PageType) { + let page = create + .create_props + .get_object_base_box() + .to_owned() + .unwrap() + .to_owned() + .to_concrete::()?; + receive_create_post(&create, &page, &request, &conn, chat_server)?; + } else if base_box.is_kind(NoteType) { + let note = create + .create_props + .get_object_base_box() + .to_owned() + .unwrap() + .to_owned() + .to_concrete::()?; + receive_create_comment(&create, ¬e, &request, &conn, chat_server)?; + } else { + return Err(format_err!("Unknown base box type")); + } + + Ok(HttpResponse::Ok().finish()) +} + +fn receive_create_post( + create: &Create, + page: &Page, + request: &HttpRequest, + conn: &PgConnection, + chat_server: ChatServerParam, +) -> Result<(), Error> { + let user_uri = create + .create_props + .get_actor_xsd_any_uri() + .unwrap() + .to_string(); + + let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?; + verify(request, &user.public_key.unwrap())?; + + let post = PostForm::from_apub(&page, &conn)?; + let inserted_post = Post::create(conn, &post)?; + + // Refetch the view + let post_view = PostView::read(&conn, inserted_post.id, None)?; + + let res = PostResponse { post: post_view }; + + chat_server.do_send(SendPost { + op: UserOperation::CreatePost, + post: res, + my_id: None, + }); + + Ok(()) +} + +fn receive_create_comment( + create: &Create, + note: &Note, + request: &HttpRequest, + conn: &PgConnection, + chat_server: ChatServerParam, +) -> Result<(), Error> { + let user_uri = create + .create_props + .get_actor_xsd_any_uri() + .unwrap() + .to_string(); + + let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?; + verify(request, &user.public_key.unwrap())?; + + let comment = CommentForm::from_apub(¬e, &conn)?; + let inserted_comment = Comment::create(conn, &comment)?; + + // Refetch the view + let comment_view = CommentView::read(&conn, inserted_comment.id, None)?; + + // TODO get those recipient actor ids from somewhere + let recipient_ids = vec![]; + let res = CommentResponse { + comment: comment_view, + recipient_ids, + }; + + chat_server.do_send(SendComment { + op: UserOperation::CreateComment, + comment: res, + my_id: None, + }); + + Ok(()) +} + +/// Handle create activities and insert them in the database. +fn handle_update( + update: &Update, + request: &HttpRequest, + conn: &PgConnection, + chat_server: ChatServerParam, +) -> Result { + let base_box = update.update_props.get_object_base_box().unwrap(); + + if base_box.is_kind(PageType) { + let page = update + .update_props + .get_object_base_box() + .to_owned() + .unwrap() + .to_owned() + .to_concrete::()?; + + receive_update_post(&update, &page, &request, &conn, chat_server)?; + } else if base_box.is_kind(NoteType) { + let note = update + .update_props + .get_object_base_box() + .to_owned() + .unwrap() + .to_owned() + .to_concrete::()?; + receive_update_comment(&update, ¬e, &request, &conn, chat_server)?; + } else { + return Err(format_err!("Unknown base box type")); + } + + Ok(HttpResponse::Ok().finish()) +} + +fn receive_update_post( + update: &Update, + page: &Page, + request: &HttpRequest, + conn: &PgConnection, + chat_server: ChatServerParam, +) -> Result<(), Error> { + let user_uri = update + .update_props + .get_actor_xsd_any_uri() + .unwrap() + .to_string(); + + let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?; + verify(request, &user.public_key.unwrap())?; + + let post = PostForm::from_apub(&page, conn)?; + let post_id = Post::read_from_apub_id(conn, &post.ap_id)?.id; + Post::update(conn, post_id, &post)?; + + // Refetch the view + let post_view = PostView::read(&conn, post_id, None)?; + + let res = PostResponse { post: post_view }; + + chat_server.do_send(SendPost { + op: UserOperation::EditPost, + post: res, + my_id: None, + }); + + Ok(()) +} + +fn receive_update_comment( + update: &Update, + note: &Note, + request: &HttpRequest, + conn: &PgConnection, + chat_server: ChatServerParam, +) -> Result<(), Error> { + let user_uri = update + .update_props + .get_actor_xsd_any_uri() + .unwrap() + .to_string(); + + let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?; + verify(request, &user.public_key.unwrap())?; + + let comment = CommentForm::from_apub(¬e, &conn)?; + let comment_id = Comment::read_from_apub_id(conn, &comment.ap_id)?.id; + Comment::update(conn, comment_id, &comment)?; + + // Refetch the view + let comment_view = CommentView::read(&conn, comment_id, None)?; + + // TODO get those recipient actor ids from somewhere + let recipient_ids = vec![]; + let res = CommentResponse { + comment: comment_view, + recipient_ids, + }; + + chat_server.do_send(SendComment { + op: UserOperation::EditComment, + comment: res, + my_id: None, + }); + + Ok(()) +} diff --git a/server/src/apub/user_inbox.rs b/server/src/apub/user_inbox.rs index 251a221c6..7c00b5bb8 100644 --- a/server/src/apub/user_inbox.rs +++ b/server/src/apub/user_inbox.rs @@ -3,8 +3,6 @@ use super::*; #[serde(untagged)] #[derive(Deserialize, Debug)] pub enum UserAcceptedObjects { - Create(Create), - Update(Update), Accept(Accept), } @@ -23,73 +21,10 @@ pub async fn user_inbox( debug!("User {} received activity: {:?}", &username, &input); match input { - UserAcceptedObjects::Create(c) => handle_create(&c, &request, &username, &conn), - UserAcceptedObjects::Update(u) => handle_update(&u, &request, &username, &conn), UserAcceptedObjects::Accept(a) => handle_accept(&a, &request, &username, &conn), } } -/// Handle create activities and insert them in the database. -fn handle_create( - create: &Create, - request: &HttpRequest, - _username: &str, - conn: &PgConnection, -) -> Result { - // TODO before this even gets named, because we don't know what type of object it is, we need - // to parse this out - let user_uri = create - .create_props - .get_actor_xsd_any_uri() - .unwrap() - .to_string(); - - let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?; - verify(request, &user.public_key.unwrap())?; - - let page = create - .create_props - .get_object_base_box() - .to_owned() - .unwrap() - .to_owned() - .to_concrete::()?; - let post = PostForm::from_apub(&page, conn)?; - Post::create(conn, &post)?; - // TODO: send the new post out via websocket - Ok(HttpResponse::Ok().finish()) -} - -/// Handle update activities and insert them in the database. -fn handle_update( - update: &Update, - request: &HttpRequest, - _username: &str, - conn: &PgConnection, -) -> Result { - let user_uri = update - .update_props - .get_actor_xsd_any_uri() - .unwrap() - .to_string(); - - let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?; - verify(request, &user.public_key.unwrap())?; - - let page = update - .update_props - .get_object_base_box() - .to_owned() - .unwrap() - .to_owned() - .to_concrete::()?; - let post = PostForm::from_apub(&page, conn)?; - let id = Post::read_from_apub_id(conn, &post.ap_id)?.id; - Post::update(conn, id, &post)?; - // TODO: send the new post out via websocket - Ok(HttpResponse::Ok().finish()) -} - /// Handle accepted follows. fn handle_accept( accept: &Accept, diff --git a/server/src/db/comment.rs b/server/src/db/comment.rs index 0b8a2e206..59c2ccd29 100644 --- a/server/src/db/comment.rs +++ b/server/src/db/comment.rs @@ -38,6 +38,7 @@ pub struct CommentForm { pub content: String, pub removed: Option, pub read: Option, + pub published: Option, pub updated: Option, pub deleted: Option, pub ap_id: String, @@ -84,6 +85,11 @@ impl Comment { .get_result::(conn) } + pub fn read_from_apub_id(conn: &PgConnection, object_id: &str) -> Result { + use crate::schema::comment::dsl::*; + comment.filter(ap_id.eq(object_id)).first::(conn) + } + pub fn mark_as_read(conn: &PgConnection, comment_id: i32) -> Result { use crate::schema::comment::dsl::*; @@ -283,6 +289,7 @@ mod tests { deleted: None, read: None, parent_id: None, + published: None, updated: None, ap_id: "changeme".into(), local: true, @@ -313,6 +320,7 @@ mod tests { removed: None, deleted: None, read: None, + published: None, updated: None, ap_id: "changeme".into(), local: true, diff --git a/server/src/db/comment_view.rs b/server/src/db/comment_view.rs index f0b97cb51..a94aa1579 100644 --- a/server/src/db/comment_view.rs +++ b/server/src/db/comment_view.rs @@ -540,6 +540,7 @@ mod tests { removed: None, deleted: None, read: None, + published: None, updated: None, ap_id: "changeme".into(), local: true, diff --git a/server/src/db/moderator.rs b/server/src/db/moderator.rs index d56e3914c..a040c0ca8 100644 --- a/server/src/db/moderator.rs +++ b/server/src/db/moderator.rs @@ -541,6 +541,7 @@ mod tests { deleted: None, read: None, parent_id: None, + published: None, updated: None, ap_id: "changeme".into(), local: true, diff --git a/server/src/db/user_mention.rs b/server/src/db/user_mention.rs index aea1e2285..d8305d395 100644 --- a/server/src/db/user_mention.rs +++ b/server/src/db/user_mention.rs @@ -167,6 +167,7 @@ mod tests { deleted: None, read: None, parent_id: None, + published: None, updated: None, ap_id: "changeme".into(), local: true, diff --git a/server/src/routes/federation.rs b/server/src/routes/federation.rs index bab88ca39..c1cb7408a 100644 --- a/server/src/routes/federation.rs +++ b/server/src/routes/federation.rs @@ -2,6 +2,7 @@ use super::*; use crate::apub::community::*; use crate::apub::community_inbox::community_inbox; use crate::apub::post::get_apub_post; +use crate::apub::shared_inbox::shared_inbox; use crate::apub::user::*; use crate::apub::user_inbox::user_inbox; use crate::apub::APUB_JSON_CONTENT_TYPE; @@ -31,6 +32,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { ) // Inboxes dont work with the header guard for some reason. .route("/c/{community_name}/inbox", web::post().to(community_inbox)) - .route("/u/{user_name}/inbox", web::post().to(user_inbox)); + .route("/u/{user_name}/inbox", web::post().to(user_inbox)) + .route("/inbox", web::post().to(shared_inbox)); } } diff --git a/ui/jest.config.js b/ui/jest.config.js index ebe914b19..abe695be9 100644 --- a/ui/jest.config.js +++ b/ui/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + testTimeout: 30000, globals: { 'ts-jest': { diagnostics: false, diff --git a/ui/src/api_tests/api.spec.ts b/ui/src/api_tests/api.spec.ts index 07e12ecfe..49fd08782 100644 --- a/ui/src/api_tests/api.spec.ts +++ b/ui/src/api_tests/api.spec.ts @@ -6,6 +6,13 @@ import { PostForm, PostResponse, SearchResponse, + FollowCommunityForm, + CommunityResponse, + GetFollowedCommunitiesResponse, + GetPostForm, + GetPostResponse, + CommentForm, + CommentResponse, } from '../interfaces'; let lemmyAlphaUrl = 'http://localhost:8540'; @@ -13,6 +20,7 @@ let lemmyBetaUrl = 'http://localhost:8550'; let lemmyAlphaApiUrl = `${lemmyAlphaUrl}/api/v1`; let lemmyBetaApiUrl = `${lemmyBetaUrl}/api/v1`; let lemmyAlphaAuth: string; +let lemmyBetaAuth: string; // Workaround for tests being run before beforeAll() is finished // https://github.com/facebook/jest/issues/9527#issuecomment-592406108 @@ -33,37 +41,287 @@ describe('main', () => { }).then(d => d.json()); lemmyAlphaAuth = res.jwt; - }); - test('Create test post on alpha and fetch it on beta', async () => { - let name = 'A jest test post'; - let postForm: PostForm = { - name, - auth: lemmyAlphaAuth, - community_id: 2, - creator_id: 2, - nsfw: false, + console.log('Logging in as lemmy_beta'); + let formB = { + username_or_email: 'lemmy_beta', + password: 'lemmy', }; - let createResponse: PostResponse = await fetch(`${lemmyAlphaApiUrl}/post`, { + let resB: LoginResponse = await fetch(`${lemmyBetaApiUrl}/user/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: wrapper(postForm), - }).then(d => d.json()); - expect(createResponse.post.name).toBe(name); - - let searchUrl = `${lemmyBetaApiUrl}/search?q=${createResponse.post.ap_id}&type_=All&sort=TopAll`; - let searchResponse: SearchResponse = await fetch(searchUrl, { - method: 'GET', + body: wrapper(formB), }).then(d => d.json()); - // TODO: check more fields - expect(searchResponse.posts[0].name).toBe(name); + lemmyBetaAuth = resB.jwt; }); - function wrapper(form: any): string { - return JSON.stringify(form); - } + describe('beta_fetch', () => { + test('Create test post on alpha and fetch it on beta', async () => { + let name = 'A jest test post'; + let postForm: PostForm = { + name, + auth: lemmyAlphaAuth, + community_id: 2, + creator_id: 2, + nsfw: false, + }; + + let createResponse: PostResponse = await fetch( + `${lemmyAlphaApiUrl}/post`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: wrapper(postForm), + } + ).then(d => d.json()); + expect(createResponse.post.name).toBe(name); + + let searchUrl = `${lemmyBetaApiUrl}/search?q=${createResponse.post.ap_id}&type_=All&sort=TopAll`; + let searchResponse: SearchResponse = await fetch(searchUrl, { + method: 'GET', + }).then(d => d.json()); + + // TODO: check more fields + expect(searchResponse.posts[0].name).toBe(name); + }); + }); + + describe('follow_accept', () => { + test('/u/lemmy_alpha follows and accepts lemmy_beta/c/main', async () => { + // Make sure lemmy_beta/c/main is cached on lemmy_alpha + let searchUrl = `${lemmyAlphaApiUrl}/search?q=http://lemmy_beta:8550/c/main&type_=All&sort=TopAll`; + let searchResponse: SearchResponse = await fetch(searchUrl, { + method: 'GET', + }).then(d => d.json()); + + expect(searchResponse.communities[0].name).toBe('main'); + + // TODO + // Unfortunately the search is correctly + let followForm: FollowCommunityForm = { + community_id: searchResponse.communities[0].id, + follow: true, + auth: lemmyAlphaAuth, + }; + + let followRes: CommunityResponse = await fetch( + `${lemmyAlphaApiUrl}/community/follow`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: wrapper(followForm), + } + ).then(d => d.json()); + + // Make sure the follow response went through + expect(followRes.community.local).toBe(false); + expect(followRes.community.name).toBe('main'); + + // Check that you are subscribed to it locally + let followedCommunitiesUrl = `${lemmyAlphaApiUrl}/user/followed_communities?&auth=${lemmyAlphaAuth}`; + let followedCommunitiesRes: GetFollowedCommunitiesResponse = await fetch( + followedCommunitiesUrl, + { + method: 'GET', + } + ).then(d => d.json()); + + expect(followedCommunitiesRes.communities[1].community_local).toBe(false); + }); + }); + + describe('create test post', () => { + test('/u/lemmy_alpha creates a post on /c/lemmy_beta/main, its on both instances', async () => { + let name = 'A jest test federated post'; + let postForm: PostForm = { + name, + auth: lemmyAlphaAuth, + community_id: 3, + creator_id: 2, + nsfw: false, + }; + + let createResponse: PostResponse = await fetch( + `${lemmyAlphaApiUrl}/post`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: wrapper(postForm), + } + ).then(d => d.json()); + + expect(createResponse.post.name).toBe(name); + expect(createResponse.post.community_local).toBe(false); + expect(createResponse.post.creator_local).toBe(true); + + let getPostUrl = `${lemmyBetaApiUrl}/post?id=2`; + let getPostRes: GetPostResponse = await fetch(getPostUrl, { + method: 'GET', + }).then(d => d.json()); + + expect(getPostRes.post.name).toBe(name); + expect(getPostRes.post.community_local).toBe(true); + expect(getPostRes.post.creator_local).toBe(false); + }); + }); + + describe('update test post', () => { + test('/u/lemmy_alpha updates a post on /c/lemmy_beta/main, the update is on both', async () => { + let name = 'A jest test federated post, updated'; + let postForm: PostForm = { + name, + edit_id: 2, + auth: lemmyAlphaAuth, + community_id: 3, + creator_id: 2, + nsfw: false, + }; + + let updateResponse: PostResponse = await fetch( + `${lemmyAlphaApiUrl}/post`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: wrapper(postForm), + } + ).then(d => d.json()); + + expect(updateResponse.post.name).toBe(name); + expect(updateResponse.post.community_local).toBe(false); + expect(updateResponse.post.creator_local).toBe(true); + + let getPostUrl = `${lemmyBetaApiUrl}/post?id=2`; + let getPostRes: GetPostResponse = await fetch(getPostUrl, { + method: 'GET', + }).then(d => d.json()); + + expect(getPostRes.post.name).toBe(name); + expect(getPostRes.post.community_local).toBe(true); + expect(getPostRes.post.creator_local).toBe(false); + }); + }); + + describe('create test comment', () => { + test('/u/lemmy_alpha creates a comment on /c/lemmy_beta/main, its on both instances', async () => { + let content = 'A jest test federated comment'; + let commentForm: CommentForm = { + content, + post_id: 2, + auth: lemmyAlphaAuth, + }; + + let createResponse: CommentResponse = await fetch( + `${lemmyAlphaApiUrl}/comment`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: wrapper(commentForm), + } + ).then(d => d.json()); + + expect(createResponse.comment.content).toBe(content); + expect(createResponse.comment.community_local).toBe(false); + expect(createResponse.comment.creator_local).toBe(true); + + let getPostUrl = `${lemmyBetaApiUrl}/post?id=2`; + let getPostRes: GetPostResponse = await fetch(getPostUrl, { + method: 'GET', + }).then(d => d.json()); + + expect(getPostRes.comments[0].content).toBe(content); + expect(getPostRes.comments[0].community_local).toBe(true); + expect(getPostRes.comments[0].creator_local).toBe(false); + + // Now do beta replying to that comment, as a child comment + let contentBeta = 'A child federated comment from beta'; + let commentFormBeta: CommentForm = { + content: contentBeta, + post_id: getPostRes.post.id, + parent_id: getPostRes.comments[0].id, + auth: lemmyBetaAuth, + }; + + let createResponseBeta: CommentResponse = await fetch( + `${lemmyBetaApiUrl}/comment`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: wrapper(commentFormBeta), + } + ).then(d => d.json()); + + expect(createResponseBeta.comment.content).toBe(contentBeta); + expect(createResponseBeta.comment.community_local).toBe(true); + expect(createResponseBeta.comment.creator_local).toBe(true); + expect(createResponseBeta.comment.parent_id).toBe(1); + + // Make sure lemmy alpha sees that new child comment from beta + let getPostUrlAlpha = `${lemmyAlphaApiUrl}/post?id=2`; + let getPostResAlpha: GetPostResponse = await fetch(getPostUrlAlpha, { + method: 'GET', + }).then(d => d.json()); + + // The newest show up first + expect(getPostResAlpha.comments[0].content).toBe(contentBeta); + expect(getPostResAlpha.comments[0].community_local).toBe(false); + expect(getPostResAlpha.comments[0].creator_local).toBe(false); + }); + }); + + describe('update test comment', () => { + test('/u/lemmy_alpha updates a comment on /c/lemmy_beta/main, its on both instances', async () => { + let content = 'A jest test federated comment update'; + let commentForm: CommentForm = { + content, + post_id: 2, + edit_id: 1, + auth: lemmyAlphaAuth, + creator_id: 2, + }; + + let updateResponse: CommentResponse = await fetch( + `${lemmyAlphaApiUrl}/comment`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: wrapper(commentForm), + } + ).then(d => d.json()); + + expect(updateResponse.comment.content).toBe(content); + expect(updateResponse.comment.community_local).toBe(false); + expect(updateResponse.comment.creator_local).toBe(true); + + let getPostUrl = `${lemmyBetaApiUrl}/post?id=2`; + let getPostRes: GetPostResponse = await fetch(getPostUrl, { + method: 'GET', + }).then(d => d.json()); + + expect(getPostRes.comments[1].content).toBe(content); + expect(getPostRes.comments[1].community_local).toBe(true); + expect(getPostRes.comments[1].creator_local).toBe(false); + }); + }); }); + +function wrapper(form: any): string { + return JSON.stringify(form); +}