From 0dda2577e11f99fdae781d277d84e78f4f845fd9 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Mon, 12 Oct 2020 16:10:09 +0200 Subject: [PATCH] Refactor apub code, split up large files --- lemmy_apub/src/activities/mod.rs | 2 + .../receive}/announce.rs | 7 +- .../receive}/create.rs | 7 +- .../receive}/delete.rs | 7 +- .../receive}/dislike.rs | 7 +- .../activities => activities/receive}/like.rs | 7 +- lemmy_apub/src/activities/receive/mod.rs | 65 ++ .../receive}/remove.rs | 10 +- lemmy_apub/src/activities/receive/undo.rs | 269 +++++++ .../src/activities/receive/undo_comment.rs | 234 ++++++ .../src/activities/receive/undo_post.rs | 225 ++++++ .../receive}/update.rs | 7 +- .../src/{ => activities/send}/comment.rs | 207 +----- lemmy_apub/src/activities/send/community.rs | 224 ++++++ lemmy_apub/src/activities/send/mod.rs | 22 + lemmy_apub/src/{ => activities/send}/post.rs | 224 +----- .../{ => activities/send}/private_message.rs | 99 +-- lemmy_apub/src/activities/send/user.rs | 122 +++ lemmy_apub/src/activity_queue.rs | 5 +- lemmy_apub/src/community.rs | 496 ------------- lemmy_apub/src/fetcher.rs | 15 +- lemmy_apub/src/http/comment.rs | 36 + lemmy_apub/src/http/community.rs | 93 +++ lemmy_apub/src/http/mod.rs | 28 + lemmy_apub/src/http/post.rs | 34 + lemmy_apub/src/http/user.rs | 26 + lemmy_apub/src/inbox/activities/mod.rs | 8 - lemmy_apub/src/inbox/activities/undo.rs | 702 ------------------ lemmy_apub/src/inbox/community_inbox.rs | 2 +- lemmy_apub/src/inbox/mod.rs | 1 - lemmy_apub/src/inbox/shared_inbox.rs | 65 +- lemmy_apub/src/inbox/user_inbox.rs | 2 +- lemmy_apub/src/lib.rs | 125 +--- lemmy_apub/src/objects/comment.rs | 147 ++++ lemmy_apub/src/objects/community.rs | 201 +++++ lemmy_apub/src/objects/mod.rs | 38 + lemmy_apub/src/objects/post.rs | 200 +++++ lemmy_apub/src/objects/private_message.rs | 103 +++ lemmy_apub/src/{ => objects}/user.rs | 134 +--- src/routes/federation.rs | 10 +- 40 files changed, 2172 insertions(+), 2044 deletions(-) create mode 100644 lemmy_apub/src/activities/mod.rs rename lemmy_apub/src/{inbox/activities => activities/receive}/announce.rs (92%) rename lemmy_apub/src/{inbox/activities => activities/receive}/create.rs (96%) rename lemmy_apub/src/{inbox/activities => activities/receive}/delete.rs (98%) rename lemmy_apub/src/{inbox/activities => activities/receive}/dislike.rs (96%) rename lemmy_apub/src/{inbox/activities => activities/receive}/like.rs (96%) create mode 100644 lemmy_apub/src/activities/receive/mod.rs rename lemmy_apub/src/{inbox/activities => activities/receive}/remove.rs (96%) create mode 100644 lemmy_apub/src/activities/receive/undo.rs create mode 100644 lemmy_apub/src/activities/receive/undo_comment.rs create mode 100644 lemmy_apub/src/activities/receive/undo_post.rs rename lemmy_apub/src/{inbox/activities => activities/receive}/update.rs (96%) rename lemmy_apub/src/{ => activities/send}/comment.rs (69%) create mode 100644 lemmy_apub/src/activities/send/community.rs create mode 100644 lemmy_apub/src/activities/send/mod.rs rename lemmy_apub/src/{ => activities/send}/post.rs (55%) rename lemmy_apub/src/{ => activities/send}/private_message.rs (55%) create mode 100644 lemmy_apub/src/activities/send/user.rs delete mode 100644 lemmy_apub/src/community.rs create mode 100644 lemmy_apub/src/http/comment.rs create mode 100644 lemmy_apub/src/http/community.rs create mode 100644 lemmy_apub/src/http/mod.rs create mode 100644 lemmy_apub/src/http/post.rs create mode 100644 lemmy_apub/src/http/user.rs delete mode 100644 lemmy_apub/src/inbox/activities/mod.rs delete mode 100644 lemmy_apub/src/inbox/activities/undo.rs create mode 100644 lemmy_apub/src/objects/comment.rs create mode 100644 lemmy_apub/src/objects/community.rs create mode 100644 lemmy_apub/src/objects/mod.rs create mode 100644 lemmy_apub/src/objects/post.rs create mode 100644 lemmy_apub/src/objects/private_message.rs rename lemmy_apub/src/{ => objects}/user.rs (56%) diff --git a/lemmy_apub/src/activities/mod.rs b/lemmy_apub/src/activities/mod.rs new file mode 100644 index 000000000..afea56e22 --- /dev/null +++ b/lemmy_apub/src/activities/mod.rs @@ -0,0 +1,2 @@ +pub mod receive; +pub mod send; diff --git a/lemmy_apub/src/inbox/activities/announce.rs b/lemmy_apub/src/activities/receive/announce.rs similarity index 92% rename from lemmy_apub/src/inbox/activities/announce.rs rename to lemmy_apub/src/activities/receive/announce.rs index d861e5f27..a553c190d 100644 --- a/lemmy_apub/src/inbox/activities/announce.rs +++ b/lemmy_apub/src/activities/receive/announce.rs @@ -1,14 +1,15 @@ -use crate::inbox::{ - activities::{ +use crate::{ + activities::receive::{ create::receive_create, delete::receive_delete, dislike::receive_dislike, like::receive_like, + receive_unhandled_activity, remove::receive_remove, undo::receive_undo, update::receive_update, }, - shared_inbox::{get_community_id_from_activity, receive_unhandled_activity}, + inbox::shared_inbox::get_community_id_from_activity, }; use activitystreams::{ activity::*, diff --git a/lemmy_apub/src/inbox/activities/create.rs b/lemmy_apub/src/activities/receive/create.rs similarity index 96% rename from lemmy_apub/src/inbox/activities/create.rs rename to lemmy_apub/src/activities/receive/create.rs index e25fdd979..4911088d0 100644 --- a/lemmy_apub/src/inbox/activities/create.rs +++ b/lemmy_apub/src/activities/receive/create.rs @@ -1,9 +1,6 @@ use crate::{ - inbox::shared_inbox::{ - announce_if_community_is_local, - get_user_from_activity, - receive_unhandled_activity, - }, + activities::receive::{announce_if_community_is_local, receive_unhandled_activity}, + inbox::shared_inbox::get_user_from_activity, ActorType, FromApub, PageExt, diff --git a/lemmy_apub/src/inbox/activities/delete.rs b/lemmy_apub/src/activities/receive/delete.rs similarity index 98% rename from lemmy_apub/src/inbox/activities/delete.rs rename to lemmy_apub/src/activities/receive/delete.rs index 2c3760e42..1490ce918 100644 --- a/lemmy_apub/src/inbox/activities/delete.rs +++ b/lemmy_apub/src/activities/receive/delete.rs @@ -1,10 +1,7 @@ use crate::{ + activities::receive::{announce_if_community_is_local, receive_unhandled_activity}, fetcher::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post}, - inbox::shared_inbox::{ - announce_if_community_is_local, - get_user_from_activity, - receive_unhandled_activity, - }, + inbox::shared_inbox::get_user_from_activity, ActorType, FromApub, GroupExt, diff --git a/lemmy_apub/src/inbox/activities/dislike.rs b/lemmy_apub/src/activities/receive/dislike.rs similarity index 96% rename from lemmy_apub/src/inbox/activities/dislike.rs rename to lemmy_apub/src/activities/receive/dislike.rs index 06a7a0066..e79414804 100644 --- a/lemmy_apub/src/inbox/activities/dislike.rs +++ b/lemmy_apub/src/activities/receive/dislike.rs @@ -1,10 +1,7 @@ use crate::{ + activities::receive::{announce_if_community_is_local, receive_unhandled_activity}, fetcher::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post}, - inbox::shared_inbox::{ - announce_if_community_is_local, - get_user_from_activity, - receive_unhandled_activity, - }, + inbox::shared_inbox::get_user_from_activity, FromApub, PageExt, }; diff --git a/lemmy_apub/src/inbox/activities/like.rs b/lemmy_apub/src/activities/receive/like.rs similarity index 96% rename from lemmy_apub/src/inbox/activities/like.rs rename to lemmy_apub/src/activities/receive/like.rs index 7b56867b4..3000e512d 100644 --- a/lemmy_apub/src/inbox/activities/like.rs +++ b/lemmy_apub/src/activities/receive/like.rs @@ -1,10 +1,7 @@ use crate::{ + activities::receive::{announce_if_community_is_local, receive_unhandled_activity}, fetcher::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post}, - inbox::shared_inbox::{ - announce_if_community_is_local, - get_user_from_activity, - receive_unhandled_activity, - }, + inbox::shared_inbox::get_user_from_activity, FromApub, PageExt, }; diff --git a/lemmy_apub/src/activities/receive/mod.rs b/lemmy_apub/src/activities/receive/mod.rs new file mode 100644 index 000000000..b765b03e9 --- /dev/null +++ b/lemmy_apub/src/activities/receive/mod.rs @@ -0,0 +1,65 @@ +use crate::{fetcher::get_or_fetch_and_upsert_community, ActorType}; +use activitystreams::{ + base::{Extends, ExtendsExt}, + object::{AsObject, ObjectExt}, +}; +use actix_web::HttpResponse; +use anyhow::Context; +use lemmy_db::user::User_; +use lemmy_utils::{location_info, LemmyError}; +use lemmy_websocket::LemmyContext; +use log::debug; +use serde::Serialize; +use std::fmt::Debug; +use url::Url; + +pub mod announce; +pub mod create; +pub mod delete; +pub mod dislike; +pub mod like; +pub mod remove; +pub mod undo; +mod undo_comment; +mod undo_post; +pub mod update; + +fn receive_unhandled_activity(activity: A) -> Result +where + A: Debug, +{ + debug!("received unhandled activity type: {:?}", activity); + Ok(HttpResponse::NotImplemented().finish()) +} + +async fn announce_if_community_is_local( + activity: T, + user: &User_, + context: &LemmyContext, +) -> Result<(), LemmyError> +where + T: AsObject, + T: Extends, + Kind: Serialize, + >::Error: From + Send + Sync + 'static, +{ + let cc = activity.cc().context(location_info!())?; + let cc = cc.as_many().context(location_info!())?; + let community_followers_uri = cc + .first() + .context(location_info!())? + .as_xsd_any_uri() + .context(location_info!())?; + // TODO: this is hacky but seems to be the only way to get the community ID + let community_uri = community_followers_uri + .to_string() + .replace("/followers", ""); + let community = get_or_fetch_and_upsert_community(&Url::parse(&community_uri)?, context).await?; + + if community.local { + community + .send_announce(activity.into_any_base()?, &user, context) + .await?; + } + Ok(()) +} diff --git a/lemmy_apub/src/inbox/activities/remove.rs b/lemmy_apub/src/activities/receive/remove.rs similarity index 96% rename from lemmy_apub/src/inbox/activities/remove.rs rename to lemmy_apub/src/activities/receive/remove.rs index 27a7775e6..09cfd0813 100644 --- a/lemmy_apub/src/inbox/activities/remove.rs +++ b/lemmy_apub/src/activities/receive/remove.rs @@ -1,11 +1,7 @@ use crate::{ + activities::receive::{announce_if_community_is_local, receive_unhandled_activity}, fetcher::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post}, - inbox::shared_inbox::{ - announce_if_community_is_local, - get_community_id_from_activity, - get_user_from_activity, - receive_unhandled_activity, - }, + inbox::shared_inbox::{get_community_id_from_activity, get_user_from_activity}, ActorType, FromApub, GroupExt, @@ -45,7 +41,7 @@ pub async fn receive_remove( let actor = get_user_from_activity(&remove, context).await?; let community = get_community_id_from_activity(&remove)?; if actor.actor_id()?.domain() != community.domain() { - return Err(anyhow!("Remove activities are only allowed on local objects").into()); + return Err(anyhow!("Remove receive are only allowed on local objects").into()); } match remove.object().as_single_kind_str() { diff --git a/lemmy_apub/src/activities/receive/undo.rs b/lemmy_apub/src/activities/receive/undo.rs new file mode 100644 index 000000000..0880c3f68 --- /dev/null +++ b/lemmy_apub/src/activities/receive/undo.rs @@ -0,0 +1,269 @@ +use crate::{ + activities::receive::{ + announce_if_community_is_local, + receive_unhandled_activity, + undo_comment::*, + undo_post::*, + }, + inbox::shared_inbox::get_user_from_activity, + ActorType, + FromApub, + GroupExt, +}; +use activitystreams::{ + activity::*, + base::{AnyBase, AsBase}, + prelude::*, +}; +use actix_web::HttpResponse; +use anyhow::{anyhow, Context}; +use lemmy_db::{ + community::{Community, CommunityForm}, + community_view::CommunityView, + naive_now, + Crud, +}; +use lemmy_structs::{blocking, community::CommunityResponse}; +use lemmy_utils::{location_info, LemmyError}; +use lemmy_websocket::{messages::SendCommunityRoomMessage, LemmyContext, UserOperation}; + +pub async fn receive_undo( + activity: AnyBase, + context: &LemmyContext, +) -> Result { + let undo = Undo::from_any_base(activity)?.context(location_info!())?; + match undo.object().as_single_kind_str() { + Some("Delete") => receive_undo_delete(undo, context).await, + Some("Remove") => receive_undo_remove(undo, context).await, + Some("Like") => receive_undo_like(undo, context).await, + Some("Dislike") => receive_undo_dislike(undo, context).await, + _ => receive_unhandled_activity(undo), + } +} + +fn check_is_undo_valid(outer_activity: &Undo, inner_activity: &T) -> Result<(), LemmyError> +where + T: AsBase + ActorAndObjectRef, +{ + let outer_actor = outer_activity.actor()?; + let outer_actor_uri = outer_actor + .as_single_xsd_any_uri() + .context(location_info!())?; + + let inner_actor = inner_activity.actor()?; + let inner_actor_uri = inner_actor + .as_single_xsd_any_uri() + .context(location_info!())?; + + if outer_actor_uri.domain() != inner_actor_uri.domain() { + Err(anyhow!("Cant undo receive from a different instance").into()) + } else { + Ok(()) + } +} + +async fn receive_undo_delete( + undo: Undo, + context: &LemmyContext, +) -> Result { + let delete = Delete::from_any_base(undo.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + check_is_undo_valid(&undo, &delete)?; + let type_ = delete + .object() + .as_single_kind_str() + .context(location_info!())?; + match type_ { + "Note" => receive_undo_delete_comment(undo, &delete, context).await, + "Page" => receive_undo_delete_post(undo, &delete, context).await, + "Group" => receive_undo_delete_community(undo, &delete, context).await, + d => Err(anyhow!("Undo Delete type {} not supported", d).into()), + } +} + +async fn receive_undo_remove( + undo: Undo, + context: &LemmyContext, +) -> Result { + let remove = Remove::from_any_base(undo.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + check_is_undo_valid(&undo, &remove)?; + + let type_ = remove + .object() + .as_single_kind_str() + .context(location_info!())?; + match type_ { + "Note" => receive_undo_remove_comment(undo, &remove, context).await, + "Page" => receive_undo_remove_post(undo, &remove, context).await, + "Group" => receive_undo_remove_community(undo, &remove, context).await, + d => Err(anyhow!("Undo Delete type {} not supported", d).into()), + } +} + +async fn receive_undo_like(undo: Undo, context: &LemmyContext) -> Result { + let like = Like::from_any_base(undo.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + check_is_undo_valid(&undo, &like)?; + + let type_ = like + .object() + .as_single_kind_str() + .context(location_info!())?; + match type_ { + "Note" => receive_undo_like_comment(undo, &like, context).await, + "Page" => receive_undo_like_post(undo, &like, context).await, + d => Err(anyhow!("Undo Delete type {} not supported", d).into()), + } +} + +async fn receive_undo_dislike( + undo: Undo, + context: &LemmyContext, +) -> Result { + let dislike = Dislike::from_any_base(undo.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + check_is_undo_valid(&undo, &dislike)?; + + let type_ = dislike + .object() + .as_single_kind_str() + .context(location_info!())?; + match type_ { + "Note" => receive_undo_dislike_comment(undo, &dislike, context).await, + "Page" => receive_undo_dislike_post(undo, &dislike, context).await, + d => Err(anyhow!("Undo Delete type {} not supported", d).into()), + } +} + +async fn receive_undo_delete_community( + undo: Undo, + delete: &Delete, + context: &LemmyContext, +) -> Result { + let user = get_user_from_activity(delete, context).await?; + let group = GroupExt::from_any_base(delete.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let community_actor_id = CommunityForm::from_apub(&group, context, Some(user.actor_id()?)) + .await? + .actor_id + .context(location_info!())?; + + let community = blocking(context.pool(), move |conn| { + Community::read_from_actor_id(conn, &community_actor_id) + }) + .await??; + + let community_form = CommunityForm { + name: community.name.to_owned(), + title: community.title.to_owned(), + description: community.description.to_owned(), + category_id: community.category_id, // Note: need to keep this due to foreign key constraint + creator_id: community.creator_id, // Note: need to keep this due to foreign key constraint + removed: None, + published: None, + updated: Some(naive_now()), + deleted: Some(false), + nsfw: community.nsfw, + actor_id: Some(community.actor_id), + local: community.local, + private_key: community.private_key, + public_key: community.public_key, + last_refreshed_at: None, + icon: Some(community.icon.to_owned()), + banner: Some(community.banner.to_owned()), + }; + + let community_id = community.id; + blocking(context.pool(), move |conn| { + Community::update(conn, community_id, &community_form) + }) + .await??; + + let community_id = community.id; + let res = CommunityResponse { + community: blocking(context.pool(), move |conn| { + CommunityView::read(conn, community_id, None) + }) + .await??, + }; + + let community_id = res.community.id; + + context.chat_server().do_send(SendCommunityRoomMessage { + op: UserOperation::EditCommunity, + response: res, + community_id, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &user, context).await?; + Ok(HttpResponse::Ok().finish()) +} + +async fn receive_undo_remove_community( + undo: Undo, + remove: &Remove, + context: &LemmyContext, +) -> Result { + let mod_ = get_user_from_activity(remove, context).await?; + let group = GroupExt::from_any_base(remove.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let community_actor_id = CommunityForm::from_apub(&group, context, Some(mod_.actor_id()?)) + .await? + .actor_id + .context(location_info!())?; + + let community = blocking(context.pool(), move |conn| { + Community::read_from_actor_id(conn, &community_actor_id) + }) + .await??; + + let community_form = CommunityForm { + name: community.name.to_owned(), + title: community.title.to_owned(), + description: community.description.to_owned(), + category_id: community.category_id, // Note: need to keep this due to foreign key constraint + creator_id: community.creator_id, // Note: need to keep this due to foreign key constraint + removed: Some(false), + published: None, + updated: Some(naive_now()), + deleted: None, + nsfw: community.nsfw, + actor_id: Some(community.actor_id), + local: community.local, + private_key: community.private_key, + public_key: community.public_key, + last_refreshed_at: None, + icon: Some(community.icon.to_owned()), + banner: Some(community.banner.to_owned()), + }; + + let community_id = community.id; + blocking(context.pool(), move |conn| { + Community::update(conn, community_id, &community_form) + }) + .await??; + + let community_id = community.id; + let res = CommunityResponse { + community: blocking(context.pool(), move |conn| { + CommunityView::read(conn, community_id, None) + }) + .await??, + }; + + let community_id = res.community.id; + + context.chat_server().do_send(SendCommunityRoomMessage { + op: UserOperation::EditCommunity, + response: res, + community_id, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &mod_, context).await?; + Ok(HttpResponse::Ok().finish()) +} diff --git a/lemmy_apub/src/activities/receive/undo_comment.rs b/lemmy_apub/src/activities/receive/undo_comment.rs new file mode 100644 index 000000000..8ff805e93 --- /dev/null +++ b/lemmy_apub/src/activities/receive/undo_comment.rs @@ -0,0 +1,234 @@ +use crate::{ + activities::receive::announce_if_community_is_local, + fetcher::get_or_fetch_and_insert_comment, + inbox::shared_inbox::get_user_from_activity, + ActorType, + FromApub, +}; +use activitystreams::{activity::*, object::Note, prelude::*}; +use actix_web::HttpResponse; +use anyhow::Context; +use lemmy_db::{ + comment::{Comment, CommentForm, CommentLike}, + comment_view::CommentView, + naive_now, + Crud, + Likeable, +}; +use lemmy_structs::{blocking, comment::CommentResponse}; +use lemmy_utils::{location_info, LemmyError}; +use lemmy_websocket::{messages::SendComment, LemmyContext, UserOperation}; + +pub(crate) async fn receive_undo_like_comment( + undo: Undo, + like: &Like, + context: &LemmyContext, +) -> Result { + let user = get_user_from_activity(like, context).await?; + let note = Note::from_any_base(like.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let comment = CommentForm::from_apub(¬e, context, None).await?; + + let comment_id = get_or_fetch_and_insert_comment(&comment.get_ap_id()?, context) + .await? + .id; + + let user_id = user.id; + blocking(context.pool(), move |conn| { + CommentLike::remove(conn, user_id, comment_id) + }) + .await??; + + // Refetch the view + let comment_view = blocking(context.pool(), move |conn| { + CommentView::read(conn, comment_id, None) + }) + .await??; + + // TODO get those recipient actor ids from somewhere + let recipient_ids = vec![]; + let res = CommentResponse { + comment: comment_view, + recipient_ids, + form_id: None, + }; + + context.chat_server().do_send(SendComment { + op: UserOperation::CreateCommentLike, + comment: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &user, context).await?; + Ok(HttpResponse::Ok().finish()) +} + +pub(crate) async fn receive_undo_dislike_comment( + undo: Undo, + dislike: &Dislike, + context: &LemmyContext, +) -> Result { + let user = get_user_from_activity(dislike, context).await?; + let note = Note::from_any_base( + dislike + .object() + .to_owned() + .one() + .context(location_info!())?, + )? + .context(location_info!())?; + + let comment = CommentForm::from_apub(¬e, context, None).await?; + + let comment_id = get_or_fetch_and_insert_comment(&comment.get_ap_id()?, context) + .await? + .id; + + let user_id = user.id; + blocking(context.pool(), move |conn| { + CommentLike::remove(conn, user_id, comment_id) + }) + .await??; + + // Refetch the view + let comment_view = blocking(context.pool(), move |conn| { + CommentView::read(conn, comment_id, None) + }) + .await??; + + // TODO get those recipient actor ids from somewhere + let recipient_ids = vec![]; + let res = CommentResponse { + comment: comment_view, + recipient_ids, + form_id: None, + }; + + context.chat_server().do_send(SendComment { + op: UserOperation::CreateCommentLike, + comment: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &user, context).await?; + Ok(HttpResponse::Ok().finish()) +} + +pub(crate) async fn receive_undo_delete_comment( + undo: Undo, + delete: &Delete, + context: &LemmyContext, +) -> Result { + let user = get_user_from_activity(delete, context).await?; + let note = Note::from_any_base(delete.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let comment_ap_id = CommentForm::from_apub(¬e, context, Some(user.actor_id()?)) + .await? + .get_ap_id()?; + + let comment = get_or_fetch_and_insert_comment(&comment_ap_id, context).await?; + + let comment_form = CommentForm { + content: comment.content.to_owned(), + parent_id: comment.parent_id, + post_id: comment.post_id, + creator_id: comment.creator_id, + removed: None, + deleted: Some(false), + read: None, + published: None, + updated: Some(naive_now()), + ap_id: Some(comment.ap_id), + local: comment.local, + }; + let comment_id = comment.id; + blocking(context.pool(), move |conn| { + Comment::update(conn, comment_id, &comment_form) + }) + .await??; + + // Refetch the view + let comment_id = comment.id; + let comment_view = blocking(context.pool(), move |conn| { + CommentView::read(conn, comment_id, None) + }) + .await??; + + // TODO get those recipient actor ids from somewhere + let recipient_ids = vec![]; + let res = CommentResponse { + comment: comment_view, + recipient_ids, + form_id: None, + }; + + context.chat_server().do_send(SendComment { + op: UserOperation::EditComment, + comment: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &user, context).await?; + Ok(HttpResponse::Ok().finish()) +} + +pub(crate) async fn receive_undo_remove_comment( + undo: Undo, + remove: &Remove, + context: &LemmyContext, +) -> Result { + let mod_ = get_user_from_activity(remove, context).await?; + let note = Note::from_any_base(remove.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let comment_ap_id = CommentForm::from_apub(¬e, context, None) + .await? + .get_ap_id()?; + + let comment = get_or_fetch_and_insert_comment(&comment_ap_id, context).await?; + + let comment_form = CommentForm { + content: comment.content.to_owned(), + parent_id: comment.parent_id, + post_id: comment.post_id, + creator_id: comment.creator_id, + removed: Some(false), + deleted: None, + read: None, + published: None, + updated: Some(naive_now()), + ap_id: Some(comment.ap_id), + local: comment.local, + }; + let comment_id = comment.id; + blocking(context.pool(), move |conn| { + Comment::update(conn, comment_id, &comment_form) + }) + .await??; + + // Refetch the view + let comment_id = comment.id; + let comment_view = blocking(context.pool(), move |conn| { + CommentView::read(conn, comment_id, None) + }) + .await??; + + // TODO get those recipient actor ids from somewhere + let recipient_ids = vec![]; + let res = CommentResponse { + comment: comment_view, + recipient_ids, + form_id: None, + }; + + context.chat_server().do_send(SendComment { + op: UserOperation::EditComment, + comment: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &mod_, context).await?; + Ok(HttpResponse::Ok().finish()) +} diff --git a/lemmy_apub/src/activities/receive/undo_post.rs b/lemmy_apub/src/activities/receive/undo_post.rs new file mode 100644 index 000000000..00527a102 --- /dev/null +++ b/lemmy_apub/src/activities/receive/undo_post.rs @@ -0,0 +1,225 @@ +use crate::{ + activities::receive::announce_if_community_is_local, + fetcher::get_or_fetch_and_insert_post, + inbox::shared_inbox::get_user_from_activity, + ActorType, + FromApub, + PageExt, +}; +use activitystreams::{activity::*, prelude::*}; +use actix_web::HttpResponse; +use anyhow::Context; +use lemmy_db::{ + naive_now, + post::{Post, PostForm, PostLike}, + post_view::PostView, + Crud, + Likeable, +}; +use lemmy_structs::{blocking, post::PostResponse}; +use lemmy_utils::{location_info, LemmyError}; +use lemmy_websocket::{messages::SendPost, LemmyContext, UserOperation}; + +pub(crate) async fn receive_undo_like_post( + undo: Undo, + like: &Like, + context: &LemmyContext, +) -> Result { + let user = get_user_from_activity(like, context).await?; + let page = PageExt::from_any_base(like.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let post = PostForm::from_apub(&page, context, None).await?; + + let post_id = get_or_fetch_and_insert_post(&post.get_ap_id()?, context) + .await? + .id; + + let user_id = user.id; + blocking(context.pool(), move |conn| { + PostLike::remove(conn, user_id, post_id) + }) + .await??; + + // Refetch the view + let post_view = blocking(context.pool(), move |conn| { + PostView::read(conn, post_id, None) + }) + .await??; + + let res = PostResponse { post: post_view }; + + context.chat_server().do_send(SendPost { + op: UserOperation::CreatePostLike, + post: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &user, context).await?; + Ok(HttpResponse::Ok().finish()) +} + +pub(crate) async fn receive_undo_dislike_post( + undo: Undo, + dislike: &Dislike, + context: &LemmyContext, +) -> Result { + let user = get_user_from_activity(dislike, context).await?; + let page = PageExt::from_any_base( + dislike + .object() + .to_owned() + .one() + .context(location_info!())?, + )? + .context(location_info!())?; + + let post = PostForm::from_apub(&page, context, None).await?; + + let post_id = get_or_fetch_and_insert_post(&post.get_ap_id()?, context) + .await? + .id; + + let user_id = user.id; + blocking(context.pool(), move |conn| { + PostLike::remove(conn, user_id, post_id) + }) + .await??; + + // Refetch the view + let post_view = blocking(context.pool(), move |conn| { + PostView::read(conn, post_id, None) + }) + .await??; + + let res = PostResponse { post: post_view }; + + context.chat_server().do_send(SendPost { + op: UserOperation::CreatePostLike, + post: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &user, context).await?; + Ok(HttpResponse::Ok().finish()) +} + +pub(crate) async fn receive_undo_delete_post( + undo: Undo, + delete: &Delete, + context: &LemmyContext, +) -> Result { + let user = get_user_from_activity(delete, context).await?; + let page = PageExt::from_any_base(delete.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let post_ap_id = PostForm::from_apub(&page, context, Some(user.actor_id()?)) + .await? + .get_ap_id()?; + + let post = get_or_fetch_and_insert_post(&post_ap_id, context).await?; + + let post_form = PostForm { + name: post.name.to_owned(), + url: post.url.to_owned(), + body: post.body.to_owned(), + creator_id: post.creator_id.to_owned(), + community_id: post.community_id, + removed: None, + deleted: Some(false), + nsfw: post.nsfw, + locked: None, + stickied: None, + updated: Some(naive_now()), + embed_title: post.embed_title, + embed_description: post.embed_description, + embed_html: post.embed_html, + thumbnail_url: post.thumbnail_url, + ap_id: Some(post.ap_id), + local: post.local, + published: None, + }; + let post_id = post.id; + blocking(context.pool(), move |conn| { + Post::update(conn, post_id, &post_form) + }) + .await??; + + // Refetch the view + let post_id = post.id; + let post_view = blocking(context.pool(), move |conn| { + PostView::read(conn, post_id, None) + }) + .await??; + + let res = PostResponse { post: post_view }; + + context.chat_server().do_send(SendPost { + op: UserOperation::EditPost, + post: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &user, context).await?; + Ok(HttpResponse::Ok().finish()) +} + +pub(crate) async fn receive_undo_remove_post( + undo: Undo, + remove: &Remove, + context: &LemmyContext, +) -> Result { + let mod_ = get_user_from_activity(remove, context).await?; + let page = PageExt::from_any_base(remove.object().to_owned().one().context(location_info!())?)? + .context(location_info!())?; + + let post_ap_id = PostForm::from_apub(&page, context, None) + .await? + .get_ap_id()?; + + let post = get_or_fetch_and_insert_post(&post_ap_id, context).await?; + + let post_form = PostForm { + name: post.name.to_owned(), + url: post.url.to_owned(), + body: post.body.to_owned(), + creator_id: post.creator_id.to_owned(), + community_id: post.community_id, + removed: Some(false), + deleted: None, + nsfw: post.nsfw, + locked: None, + stickied: None, + updated: Some(naive_now()), + embed_title: post.embed_title, + embed_description: post.embed_description, + embed_html: post.embed_html, + thumbnail_url: post.thumbnail_url, + ap_id: Some(post.ap_id), + local: post.local, + published: None, + }; + let post_id = post.id; + blocking(context.pool(), move |conn| { + Post::update(conn, post_id, &post_form) + }) + .await??; + + // Refetch the view + let post_id = post.id; + let post_view = blocking(context.pool(), move |conn| { + PostView::read(conn, post_id, None) + }) + .await??; + + let res = PostResponse { post: post_view }; + + context.chat_server().do_send(SendPost { + op: UserOperation::EditPost, + post: res, + websocket_id: None, + }); + + announce_if_community_is_local(undo, &mod_, context).await?; + Ok(HttpResponse::Ok().finish()) +} diff --git a/lemmy_apub/src/inbox/activities/update.rs b/lemmy_apub/src/activities/receive/update.rs similarity index 96% rename from lemmy_apub/src/inbox/activities/update.rs rename to lemmy_apub/src/activities/receive/update.rs index 17d9d7084..a18c6fe92 100644 --- a/lemmy_apub/src/inbox/activities/update.rs +++ b/lemmy_apub/src/activities/receive/update.rs @@ -1,10 +1,7 @@ use crate::{ + activities::receive::{announce_if_community_is_local, receive_unhandled_activity}, fetcher::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post}, - inbox::shared_inbox::{ - announce_if_community_is_local, - get_user_from_activity, - receive_unhandled_activity, - }, + inbox::shared_inbox::get_user_from_activity, ActorType, FromApub, PageExt, diff --git a/lemmy_apub/src/comment.rs b/lemmy_apub/src/activities/send/comment.rs similarity index 69% rename from lemmy_apub/src/comment.rs rename to lemmy_apub/src/activities/send/comment.rs index e06fe71ea..af31dc433 100644 --- a/lemmy_apub/src/comment.rs +++ b/lemmy_apub/src/activities/send/comment.rs @@ -1,20 +1,10 @@ use crate::{ + activities::send::generate_activity_id, activity_queue::{send_comment_mentions, send_to_community}, - check_actor_domain, - create_apub_response, - create_apub_tombstone_response, - create_tombstone, - fetch_webfinger_url, - fetcher::{ - get_or_fetch_and_insert_comment, - get_or_fetch_and_insert_post, - get_or_fetch_and_upsert_user, - }, - generate_activity_id, + fetcher::get_or_fetch_and_upsert_user, ActorType, ApubLikeableType, ApubObjectType, - FromApub, ToApub, }; use activitystreams::{ @@ -30,174 +20,25 @@ use activitystreams::{ }, base::AnyBase, link::Mention, - object::{kind::NoteType, Note, Tombstone}, prelude::*, public, }; -use actix_web::{body::Body, web, web::Path, HttpResponse}; -use anyhow::Context; -use diesel::result::Error::NotFound; +use anyhow::anyhow; use itertools::Itertools; -use lemmy_db::{ - comment::{Comment, CommentForm}, - community::Community, - post::Post, - user::User_, - Crud, - DbPool, -}; -use lemmy_structs::blocking; +use lemmy_db::{comment::Comment, community::Community, post::Post, user::User_, Crud}; +use lemmy_structs::{blocking, WebFingerResponse}; use lemmy_utils::{ - location_info, - utils::{convert_datetime, remove_slurs, scrape_text_for_mentions, MentionData}, + request::{retry, RecvError}, + settings::Settings, + utils::{scrape_text_for_mentions, MentionData}, LemmyError, }; use lemmy_websocket::LemmyContext; use log::debug; -use serde::Deserialize; +use reqwest::Client; use serde_json::Error; use url::Url; -#[derive(Deserialize)] -pub struct CommentQuery { - comment_id: String, -} - -/// Return the post json over HTTP. -pub async fn get_apub_comment( - info: Path, - context: web::Data, -) -> Result, LemmyError> { - let id = info.comment_id.parse::()?; - let comment = blocking(context.pool(), move |conn| Comment::read(conn, id)).await??; - if !comment.local { - return Err(NotFound.into()); - } - - if !comment.deleted { - Ok(create_apub_response( - &comment.to_apub(context.pool()).await?, - )) - } else { - Ok(create_apub_tombstone_response(&comment.to_tombstone()?)) - } -} - -#[async_trait::async_trait(?Send)] -impl ToApub for Comment { - type Response = Note; - - async fn to_apub(&self, pool: &DbPool) -> Result { - let mut comment = Note::new(); - - let creator_id = self.creator_id; - let creator = blocking(pool, move |conn| User_::read(conn, creator_id)).await??; - - let post_id = self.post_id; - let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??; - - let community_id = post.community_id; - let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??; - - // 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 = blocking(pool, move |conn| Comment::read(conn, parent_id)).await??; - - in_reply_to_vec.push(parent_comment.ap_id); - } - - comment - // Not needed when the Post is embedded in a collection (like for community outbox) - .set_context(activitystreams::context()) - .set_id(Url::parse(&self.ap_id)?) - .set_published(convert_datetime(self.published)) - .set_to(community.actor_id) - .set_many_in_reply_tos(in_reply_to_vec) - .set_content(self.content.to_owned()) - .set_attributed_to(creator.actor_id); - - if let Some(u) = self.updated { - comment.set_updated(convert_datetime(u)); - } - - Ok(comment) - } - - fn to_tombstone(&self) -> Result { - create_tombstone(self.deleted, &self.ap_id, self.updated, NoteType::Note) - } -} - -#[async_trait::async_trait(?Send)] -impl FromApub for CommentForm { - type ApubType = Note; - - /// Parse an ActivityPub note received from another instance into a Lemmy comment - async fn from_apub( - note: &Note, - context: &LemmyContext, - expected_domain: Option, - ) -> Result { - let creator_actor_id = ¬e - .attributed_to() - .context(location_info!())? - .as_single_xsd_any_uri() - .context(location_info!())?; - - let creator = get_or_fetch_and_upsert_user(creator_actor_id, context).await?; - - let mut in_reply_tos = note - .in_reply_to() - .as_ref() - .context(location_info!())? - .as_many() - .context(location_info!())? - .iter() - .map(|i| i.as_xsd_any_uri().context("")); - let post_ap_id = in_reply_tos.next().context(location_info!())??; - - // This post, or the parent comment might not yet exist on this server yet, fetch them. - let post = get_or_fetch_and_insert_post(&post_ap_id, context).await?; - - // The 2nd item, if it exists, is the parent comment apub_id - // For deeply nested comments, FromApub automatically gets called recursively - let parent_id: Option = match in_reply_tos.next() { - Some(parent_comment_uri) => { - let parent_comment_ap_id = &parent_comment_uri?; - let parent_comment = - get_or_fetch_and_insert_comment(&parent_comment_ap_id, context).await?; - - Some(parent_comment.id) - } - None => None, - }; - let content = note - .content() - .context(location_info!())? - .as_single_xsd_string() - .context(location_info!())? - .to_string(); - let content_slurs_removed = remove_slurs(&content); - - Ok(CommentForm { - creator_id: creator.id, - post_id: post.id, - parent_id, - content: content_slurs_removed, - removed: None, - read: None, - published: note.published().map(|u| u.to_owned().naive_local()), - updated: note.updated().map(|u| u.to_owned().naive_local()), - deleted: None, - ap_id: Some(check_actor_domain(note, expected_domain)?), - local: false, - }) - } -} - #[async_trait::async_trait(?Send)] impl ApubObjectType for Comment { /// Send out information about a newly created comment, to the followers of the community. @@ -518,3 +359,33 @@ async fn collect_non_local_mentions_and_addresses( tags, }) } + +async fn fetch_webfinger_url(mention: &MentionData, client: &Client) -> Result { + let fetch_url = format!( + "{}://{}/.well-known/webfinger?resource=acct:{}@{}", + Settings::get().get_protocol_string(), + mention.domain, + mention.name, + mention.domain + ); + debug!("Fetching webfinger url: {}", &fetch_url); + + let response = retry(|| client.get(&fetch_url).send()).await?; + + let res: WebFingerResponse = response + .json() + .await + .map_err(|e| RecvError(e.to_string()))?; + + let link = res + .links + .iter() + .find(|l| l.type_.eq(&Some("application/activity+json".to_string()))) + .ok_or_else(|| anyhow!("No application/activity+json link found."))?; + link + .href + .to_owned() + .map(|u| Url::parse(&u)) + .transpose()? + .ok_or_else(|| anyhow!("No href found.").into()) +} diff --git a/lemmy_apub/src/activities/send/community.rs b/lemmy_apub/src/activities/send/community.rs new file mode 100644 index 000000000..ab34f3367 --- /dev/null +++ b/lemmy_apub/src/activities/send/community.rs @@ -0,0 +1,224 @@ +use crate::{ + activities::send::generate_activity_id, + activity_queue::{send_activity_single_dest, send_to_community_followers}, + check_is_apub_id_valid, + fetcher::get_or_fetch_and_upsert_actor, + ActorType, + ToApub, +}; +use activitystreams::{ + activity::{ + kind::{AcceptType, AnnounceType, DeleteType, LikeType, RemoveType, UndoType}, + Accept, + ActorAndObjectRefExt, + Announce, + Delete, + Follow, + Remove, + Undo, + }, + base::{AnyBase, BaseExt, ExtendsExt}, + object::ObjectExt, + public, +}; +use anyhow::Context; +use itertools::Itertools; +use lemmy_db::{community::Community, community_view::CommunityFollowerView, user::User_, DbPool}; +use lemmy_structs::blocking; +use lemmy_utils::{location_info, settings::Settings, LemmyError}; +use lemmy_websocket::LemmyContext; +use url::Url; + +#[async_trait::async_trait(?Send)] +impl ActorType for Community { + fn actor_id_str(&self) -> String { + self.actor_id.to_owned() + } + + fn public_key(&self) -> Option { + self.public_key.to_owned() + } + fn private_key(&self) -> Option { + self.private_key.to_owned() + } + + fn user_id(&self) -> i32 { + self.creator_id + } + + async fn send_follow( + &self, + _follow_actor_id: &Url, + _context: &LemmyContext, + ) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn send_unfollow( + &self, + _follow_actor_id: &Url, + _context: &LemmyContext, + ) -> Result<(), LemmyError> { + unimplemented!() + } + + /// As a local community, accept the follow request from a remote user. + async fn send_accept_follow( + &self, + follow: Follow, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let actor_uri = follow + .actor()? + .as_single_xsd_any_uri() + .context(location_info!())?; + let actor = get_or_fetch_and_upsert_actor(actor_uri, context).await?; + + let mut accept = Accept::new(self.actor_id.to_owned(), follow.into_any_base()?); + let to = actor.get_inbox_url()?; + accept + .set_context(activitystreams::context()) + .set_id(generate_activity_id(AcceptType::Accept)?) + .set_to(to.clone()); + + send_activity_single_dest(accept, self, to, context).await?; + Ok(()) + } + + async fn send_delete(&self, creator: &User_, context: &LemmyContext) -> Result<(), LemmyError> { + let group = self.to_apub(context.pool()).await?; + + let mut delete = Delete::new(creator.actor_id.to_owned(), group.into_any_base()?); + delete + .set_context(activitystreams::context()) + .set_id(generate_activity_id(DeleteType::Delete)?) + .set_to(public()) + .set_many_ccs(vec![self.get_followers_url()?]); + + send_to_community_followers(delete, self, context, None).await?; + Ok(()) + } + + async fn send_undo_delete( + &self, + creator: &User_, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let group = self.to_apub(context.pool()).await?; + + let mut delete = Delete::new(creator.actor_id.to_owned(), group.into_any_base()?); + delete + .set_context(activitystreams::context()) + .set_id(generate_activity_id(DeleteType::Delete)?) + .set_to(public()) + .set_many_ccs(vec![self.get_followers_url()?]); + + let mut undo = Undo::new(creator.actor_id.to_owned(), delete.into_any_base()?); + undo + .set_context(activitystreams::context()) + .set_id(generate_activity_id(UndoType::Undo)?) + .set_to(public()) + .set_many_ccs(vec![self.get_followers_url()?]); + + send_to_community_followers(undo, self, context, None).await?; + Ok(()) + } + + async fn send_remove(&self, mod_: &User_, context: &LemmyContext) -> Result<(), LemmyError> { + let group = self.to_apub(context.pool()).await?; + + let mut remove = Remove::new(mod_.actor_id.to_owned(), group.into_any_base()?); + remove + .set_context(activitystreams::context()) + .set_id(generate_activity_id(RemoveType::Remove)?) + .set_to(public()) + .set_many_ccs(vec![self.get_followers_url()?]); + + send_to_community_followers(remove, self, context, None).await?; + Ok(()) + } + + async fn send_undo_remove(&self, mod_: &User_, context: &LemmyContext) -> Result<(), LemmyError> { + let group = self.to_apub(context.pool()).await?; + + let mut remove = Remove::new(mod_.actor_id.to_owned(), group.into_any_base()?); + remove + .set_context(activitystreams::context()) + .set_id(generate_activity_id(RemoveType::Remove)?) + .set_to(public()) + .set_many_ccs(vec![self.get_followers_url()?]); + + // Undo that fake activity + let mut undo = Undo::new(mod_.actor_id.to_owned(), remove.into_any_base()?); + undo + .set_context(activitystreams::context()) + .set_id(generate_activity_id(LikeType::Like)?) + .set_to(public()) + .set_many_ccs(vec![self.get_followers_url()?]); + + send_to_community_followers(undo, self, context, None).await?; + Ok(()) + } + + async fn send_announce( + &self, + activity: AnyBase, + sender: &User_, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let mut announce = Announce::new(self.actor_id.to_owned(), activity); + announce + .set_context(activitystreams::context()) + .set_id(generate_activity_id(AnnounceType::Announce)?) + .set_to(public()) + .set_many_ccs(vec![self.get_followers_url()?]); + + send_to_community_followers( + announce, + self, + context, + Some(sender.get_shared_inbox_url()?), + ) + .await?; + + Ok(()) + } + + /// For a given community, returns the inboxes of all followers. + /// + /// TODO: this function is very badly implemented, we should just store shared_inbox_url in + /// CommunityFollowerView + async fn get_follower_inboxes(&self, pool: &DbPool) -> Result, LemmyError> { + let id = self.id; + + let inboxes = blocking(pool, move |conn| { + CommunityFollowerView::for_community(conn, id) + }) + .await??; + let inboxes = inboxes + .into_iter() + .filter(|i| !i.user_local) + .map(|u| -> Result { + let url = Url::parse(&u.user_actor_id)?; + let domain = url.domain().context(location_info!())?; + let port = if let Some(port) = url.port() { + format!(":{}", port) + } else { + "".to_string() + }; + Ok(Url::parse(&format!( + "{}://{}{}/inbox", + Settings::get().get_protocol_string(), + domain, + port, + ))?) + }) + .filter_map(Result::ok) + // Don't send to blocked instances + .filter(|inbox| check_is_apub_id_valid(inbox).is_ok()) + .unique() + .collect(); + + Ok(inboxes) + } +} diff --git a/lemmy_apub/src/activities/send/mod.rs b/lemmy_apub/src/activities/send/mod.rs new file mode 100644 index 000000000..22cc10f4a --- /dev/null +++ b/lemmy_apub/src/activities/send/mod.rs @@ -0,0 +1,22 @@ +use lemmy_utils::settings::Settings; +use url::{ParseError, Url}; +use uuid::Uuid; + +pub mod comment; +pub mod community; +pub mod post; +pub mod private_message; +pub mod user; + +fn generate_activity_id(kind: T) -> Result +where + T: ToString, +{ + let id = format!( + "{}/receive/{}/{}", + Settings::get().get_protocol_and_hostname(), + kind.to_string().to_lowercase(), + Uuid::new_v4() + ); + Url::parse(&id) +} diff --git a/lemmy_apub/src/post.rs b/lemmy_apub/src/activities/send/post.rs similarity index 55% rename from lemmy_apub/src/post.rs rename to lemmy_apub/src/activities/send/post.rs index 8dd8357dc..f7c27964c 100644 --- a/lemmy_apub/src/post.rs +++ b/lemmy_apub/src/activities/send/post.rs @@ -1,17 +1,9 @@ use crate::{ + activities::send::generate_activity_id, activity_queue::send_to_community, - check_actor_domain, - create_apub_response, - create_apub_tombstone_response, - create_tombstone, - extensions::page_extension::PageExtension, - fetcher::{get_or_fetch_and_upsert_community, get_or_fetch_and_upsert_user}, - generate_activity_id, ActorType, ApubLikeableType, ApubObjectType, - FromApub, - PageExt, ToApub, }; use activitystreams::{ @@ -25,223 +17,13 @@ use activitystreams::{ Undo, Update, }, - object::{kind::PageType, Image, Page, Tombstone}, prelude::*, public, }; -use activitystreams_ext::Ext1; -use actix_web::{body::Body, web, HttpResponse}; -use anyhow::Context; -use diesel::result::Error::NotFound; -use lemmy_db::{ - community::Community, - post::{Post, PostForm}, - user::User_, - Crud, - DbPool, -}; +use lemmy_db::{community::Community, post::Post, user::User_, Crud}; use lemmy_structs::blocking; -use lemmy_utils::{ - location_info, - request::fetch_iframely_and_pictrs_data, - utils::{check_slurs, convert_datetime, remove_slurs}, - LemmyError, -}; +use lemmy_utils::LemmyError; use lemmy_websocket::LemmyContext; -use serde::Deserialize; -use url::Url; - -#[derive(Deserialize)] -pub struct PostQuery { - post_id: String, -} - -/// Return the post json over HTTP. -pub async fn get_apub_post( - info: web::Path, - context: web::Data, -) -> Result, LemmyError> { - let id = info.post_id.parse::()?; - let post = blocking(context.pool(), move |conn| Post::read(conn, id)).await??; - if !post.local { - return Err(NotFound.into()); - } - - if !post.deleted { - Ok(create_apub_response(&post.to_apub(context.pool()).await?)) - } else { - Ok(create_apub_tombstone_response(&post.to_tombstone()?)) - } -} - -#[async_trait::async_trait(?Send)] -impl ToApub for Post { - type Response = PageExt; - - // Turn a Lemmy post into an ActivityPub page that can be sent out over the network. - async fn to_apub(&self, pool: &DbPool) -> Result { - let mut page = Page::new(); - - let creator_id = self.creator_id; - let creator = blocking(pool, move |conn| User_::read(conn, creator_id)).await??; - - let community_id = self.community_id; - let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??; - - page - // Not needed when the Post is embedded in a collection (like for community outbox) - // TODO: need to set proper context defining sensitive/commentsEnabled fields - // https://git.asonix.dog/Aardwolf/activitystreams/issues/5 - .set_context(activitystreams::context()) - .set_id(self.ap_id.parse::()?) - // Use summary field to be consistent with mastodon content warning. - // https://mastodon.xyz/@Louisa/103987265222901387.json - .set_summary(self.name.to_owned()) - .set_published(convert_datetime(self.published)) - .set_to(community.actor_id) - .set_attributed_to(creator.actor_id); - - if let Some(body) = &self.body { - page.set_content(body.to_owned()); - } - - // TODO: hacky code because we get self.url == Some("") - // https://github.com/LemmyNet/lemmy/issues/602 - let url = self.url.as_ref().filter(|u| !u.is_empty()); - if let Some(u) = url { - page.set_url(u.to_owned()); - } - - if let Some(thumbnail_url) = &self.thumbnail_url { - let mut image = Image::new(); - image.set_url(thumbnail_url.to_string()); - page.set_image(image.into_any_base()?); - } - - if let Some(u) = self.updated { - page.set_updated(convert_datetime(u)); - } - - let ext = PageExtension { - comments_enabled: !self.locked, - sensitive: self.nsfw, - stickied: self.stickied, - }; - Ok(Ext1::new(page, ext)) - } - - fn to_tombstone(&self) -> Result { - create_tombstone(self.deleted, &self.ap_id, self.updated, PageType::Page) - } -} - -#[async_trait::async_trait(?Send)] -impl FromApub for PostForm { - type ApubType = PageExt; - - /// Parse an ActivityPub page received from another instance into a Lemmy post. - async fn from_apub( - page: &PageExt, - context: &LemmyContext, - expected_domain: Option, - ) -> Result { - let ext = &page.ext_one; - let creator_actor_id = page - .inner - .attributed_to() - .as_ref() - .context(location_info!())? - .as_single_xsd_any_uri() - .context(location_info!())?; - - let creator = get_or_fetch_and_upsert_user(creator_actor_id, context).await?; - - let community_actor_id = page - .inner - .to() - .as_ref() - .context(location_info!())? - .as_single_xsd_any_uri() - .context(location_info!())?; - - let community = get_or_fetch_and_upsert_community(community_actor_id, context).await?; - - let thumbnail_url = match &page.inner.image() { - Some(any_image) => Image::from_any_base( - any_image - .to_owned() - .as_one() - .context(location_info!())? - .to_owned(), - )? - .context(location_info!())? - .url() - .context(location_info!())? - .as_single_xsd_any_uri() - .map(|u| u.to_string()), - None => None, - }; - let url = page - .inner - .url() - .map(|u| u.as_single_xsd_any_uri()) - .flatten() - .map(|s| s.to_string()); - - let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) = - if let Some(url) = &url { - fetch_iframely_and_pictrs_data(context.client(), Some(url.to_owned())).await - } else { - (None, None, None, thumbnail_url) - }; - - let name = page - .inner - .summary() - .as_ref() - .context(location_info!())? - .as_single_xsd_string() - .context(location_info!())? - .to_string(); - let body = page - .inner - .content() - .as_ref() - .map(|c| c.as_single_xsd_string()) - .flatten() - .map(|s| s.to_string()); - check_slurs(&name)?; - let body_slurs_removed = body.map(|b| remove_slurs(&b)); - Ok(PostForm { - name, - url, - body: body_slurs_removed, - creator_id: creator.id, - community_id: community.id, - removed: None, - locked: Some(!ext.comments_enabled), - published: page - .inner - .published() - .as_ref() - .map(|u| u.to_owned().naive_local()), - updated: page - .inner - .updated() - .as_ref() - .map(|u| u.to_owned().naive_local()), - deleted: None, - nsfw: ext.sensitive, - stickied: Some(ext.stickied), - embed_title: iframely_title, - embed_description: iframely_description, - embed_html: iframely_html, - thumbnail_url: pictrs_thumbnail, - ap_id: Some(check_actor_domain(page, expected_domain)?), - local: false, - }) - } -} #[async_trait::async_trait(?Send)] impl ApubObjectType for Post { diff --git a/lemmy_apub/src/private_message.rs b/lemmy_apub/src/activities/send/private_message.rs similarity index 55% rename from lemmy_apub/src/private_message.rs rename to lemmy_apub/src/activities/send/private_message.rs index fd8e6c6b0..8c3f5aa9c 100644 --- a/lemmy_apub/src/private_message.rs +++ b/lemmy_apub/src/activities/send/private_message.rs @@ -1,13 +1,8 @@ use crate::{ + activities::send::generate_activity_id, activity_queue::send_activity_single_dest, - check_actor_domain, - check_is_apub_id_valid, - create_tombstone, - fetcher::get_or_fetch_and_upsert_user, - generate_activity_id, ActorType, ApubObjectType, - FromApub, ToApub, }; use activitystreams::{ @@ -18,100 +13,12 @@ use activitystreams::{ Undo, Update, }, - object::{kind::NoteType, Note, Tombstone}, prelude::*, }; -use anyhow::Context; -use lemmy_db::{ - private_message::{PrivateMessage, PrivateMessageForm}, - user::User_, - Crud, - DbPool, -}; +use lemmy_db::{private_message::PrivateMessage, user::User_, Crud}; use lemmy_structs::blocking; -use lemmy_utils::{location_info, utils::convert_datetime, LemmyError}; +use lemmy_utils::LemmyError; use lemmy_websocket::LemmyContext; -use url::Url; - -#[async_trait::async_trait(?Send)] -impl ToApub for PrivateMessage { - type Response = Note; - - async fn to_apub(&self, pool: &DbPool) -> Result { - let mut private_message = Note::new(); - - let creator_id = self.creator_id; - let creator = blocking(pool, move |conn| User_::read(conn, creator_id)).await??; - - let recipient_id = self.recipient_id; - let recipient = blocking(pool, move |conn| User_::read(conn, recipient_id)).await??; - - private_message - .set_context(activitystreams::context()) - .set_id(Url::parse(&self.ap_id.to_owned())?) - .set_published(convert_datetime(self.published)) - .set_content(self.content.to_owned()) - .set_to(recipient.actor_id) - .set_attributed_to(creator.actor_id); - - if let Some(u) = self.updated { - private_message.set_updated(convert_datetime(u)); - } - - Ok(private_message) - } - - fn to_tombstone(&self) -> Result { - create_tombstone(self.deleted, &self.ap_id, self.updated, NoteType::Note) - } -} - -#[async_trait::async_trait(?Send)] -impl FromApub for PrivateMessageForm { - type ApubType = Note; - - /// Parse an ActivityPub note received from another instance into a Lemmy Private message - async fn from_apub( - note: &Note, - context: &LemmyContext, - expected_domain: Option, - ) -> Result { - let creator_actor_id = note - .attributed_to() - .context(location_info!())? - .clone() - .single_xsd_any_uri() - .context(location_info!())?; - - let creator = get_or_fetch_and_upsert_user(&creator_actor_id, context).await?; - let recipient_actor_id = note - .to() - .context(location_info!())? - .clone() - .single_xsd_any_uri() - .context(location_info!())?; - let recipient = get_or_fetch_and_upsert_user(&recipient_actor_id, context).await?; - let ap_id = note.id_unchecked().context(location_info!())?.to_string(); - check_is_apub_id_valid(&Url::parse(&ap_id)?)?; - - Ok(PrivateMessageForm { - creator_id: creator.id, - recipient_id: recipient.id, - content: note - .content() - .context(location_info!())? - .as_single_xsd_string() - .context(location_info!())? - .to_string(), - published: note.published().map(|u| u.to_owned().naive_local()), - updated: note.updated().map(|u| u.to_owned().naive_local()), - deleted: None, - read: None, - ap_id: Some(check_actor_domain(note, expected_domain)?), - local: false, - }) - } -} #[async_trait::async_trait(?Send)] impl ApubObjectType for PrivateMessage { diff --git a/lemmy_apub/src/activities/send/user.rs b/lemmy_apub/src/activities/send/user.rs new file mode 100644 index 000000000..08f74d943 --- /dev/null +++ b/lemmy_apub/src/activities/send/user.rs @@ -0,0 +1,122 @@ +use crate::{ + activities::send::generate_activity_id, + activity_queue::send_activity_single_dest, + fetcher::get_or_fetch_and_upsert_actor, + ActorType, +}; +use activitystreams::{ + activity::{ + kind::{FollowType, UndoType}, + Follow, + Undo, + }, + base::{AnyBase, BaseExt, ExtendsExt}, +}; +use lemmy_db::{user::User_, DbPool}; +use lemmy_utils::LemmyError; +use lemmy_websocket::LemmyContext; +use url::Url; + +#[async_trait::async_trait(?Send)] +impl ActorType for User_ { + fn actor_id_str(&self) -> String { + self.actor_id.to_owned() + } + + fn public_key(&self) -> Option { + self.public_key.to_owned() + } + + fn private_key(&self) -> Option { + self.private_key.to_owned() + } + + fn user_id(&self) -> i32 { + self.id + } + + /// As a given local user, send out a follow request to a remote community. + async fn send_follow( + &self, + follow_actor_id: &Url, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let mut follow = Follow::new(self.actor_id.to_owned(), follow_actor_id.as_str()); + follow + .set_context(activitystreams::context()) + .set_id(generate_activity_id(FollowType::Follow)?); + let follow_actor = get_or_fetch_and_upsert_actor(follow_actor_id, context).await?; + let to = follow_actor.get_inbox_url()?; + + send_activity_single_dest(follow, self, to, context).await?; + Ok(()) + } + + async fn send_unfollow( + &self, + follow_actor_id: &Url, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let mut follow = Follow::new(self.actor_id.to_owned(), follow_actor_id.as_str()); + follow + .set_context(activitystreams::context()) + .set_id(generate_activity_id(FollowType::Follow)?); + let follow_actor = get_or_fetch_and_upsert_actor(follow_actor_id, context).await?; + + let to = follow_actor.get_inbox_url()?; + + // Undo that fake activity + let mut undo = Undo::new(Url::parse(&self.actor_id)?, follow.into_any_base()?); + undo + .set_context(activitystreams::context()) + .set_id(generate_activity_id(UndoType::Undo)?); + + send_activity_single_dest(undo, self, to, context).await?; + Ok(()) + } + + async fn send_accept_follow( + &self, + _follow: Follow, + _context: &LemmyContext, + ) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn send_delete(&self, _creator: &User_, _context: &LemmyContext) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn send_undo_delete( + &self, + _creator: &User_, + _context: &LemmyContext, + ) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn send_remove(&self, _creator: &User_, _context: &LemmyContext) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn send_undo_remove( + &self, + _creator: &User_, + _context: &LemmyContext, + ) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn send_announce( + &self, + _activity: AnyBase, + _sender: &User_, + _context: &LemmyContext, + ) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn get_follower_inboxes(&self, _pool: &DbPool) -> Result, LemmyError> { + unimplemented!() + } +} diff --git a/lemmy_apub/src/activity_queue.rs b/lemmy_apub/src/activity_queue.rs index 0e018c8d8..379618f29 100644 --- a/lemmy_apub/src/activity_queue.rs +++ b/lemmy_apub/src/activity_queue.rs @@ -1,6 +1,5 @@ use crate::{ check_is_apub_id_valid, - community::do_announce, extensions::signatures::sign_and_send, insert_activity, ActorType, @@ -112,7 +111,9 @@ where { // if this is a local community, we need to do an announce from the community instead if community.local { - do_announce(activity.into_any_base()?, &community, creator, context).await?; + community + .send_announce(activity.into_any_base()?, creator, context) + .await?; } else { let inbox = community.get_shared_inbox_url()?; check_is_apub_id_valid(&inbox)?; diff --git a/lemmy_apub/src/community.rs b/lemmy_apub/src/community.rs deleted file mode 100644 index 474a63f49..000000000 --- a/lemmy_apub/src/community.rs +++ /dev/null @@ -1,496 +0,0 @@ -use crate::{ - activity_queue::{send_activity_single_dest, send_to_community_followers}, - check_actor_domain, - check_is_apub_id_valid, - create_apub_response, - create_apub_tombstone_response, - create_tombstone, - extensions::group_extensions::GroupExtension, - fetcher::{get_or_fetch_and_upsert_actor, get_or_fetch_and_upsert_user}, - generate_activity_id, - ActorType, - FromApub, - GroupExt, - ToApub, -}; -use activitystreams::{ - activity::{ - kind::{AcceptType, AnnounceType, DeleteType, LikeType, RemoveType, UndoType}, - Accept, - Announce, - Delete, - Follow, - Remove, - Undo, - }, - actor::{kind::GroupType, ApActor, Endpoints, Group}, - base::{AnyBase, BaseExt}, - collection::{OrderedCollection, UnorderedCollection}, - object::{Image, Tombstone}, - prelude::*, - public, -}; -use activitystreams_ext::Ext2; -use actix_web::{body::Body, web, HttpResponse}; -use anyhow::Context; -use itertools::Itertools; -use lemmy_db::{ - community::{Community, CommunityForm}, - community_view::{CommunityFollowerView, CommunityModeratorView}, - naive_now, - post::Post, - user::User_, - DbPool, -}; -use lemmy_structs::blocking; -use lemmy_utils::{ - location_info, - settings::Settings, - utils::{check_slurs, check_slurs_opt, convert_datetime}, - LemmyError, -}; -use lemmy_websocket::LemmyContext; -use serde::Deserialize; -use url::Url; - -#[derive(Deserialize)] -pub struct CommunityQuery { - community_name: String, -} - -#[async_trait::async_trait(?Send)] -impl ToApub for Community { - type Response = GroupExt; - - // Turn a Lemmy Community into an ActivityPub group that can be sent out over the network. - async fn to_apub(&self, pool: &DbPool) -> Result { - // The attributed to, is an ordered vector with the creator actor_ids first, - // then the rest of the moderators - // TODO Technically the instance admins can mod the community, but lets - // ignore that for now - let id = self.id; - let moderators = blocking(pool, move |conn| { - CommunityModeratorView::for_community(&conn, id) - }) - .await??; - let moderators: Vec = moderators.into_iter().map(|m| m.user_actor_id).collect(); - - let mut group = Group::new(); - group - .set_context(activitystreams::context()) - .set_id(Url::parse(&self.actor_id)?) - .set_name(self.name.to_owned()) - .set_published(convert_datetime(self.published)) - .set_many_attributed_tos(moderators); - - if let Some(u) = self.updated.to_owned() { - group.set_updated(convert_datetime(u)); - } - if let Some(d) = self.description.to_owned() { - // TODO: this should be html, also add source field with raw markdown - // -> same for post.content and others - group.set_content(d); - } - - if let Some(icon) = &self.icon { - let mut image = Image::new(); - image.set_url(icon.to_owned()); - group.set_icon(image.into_any_base()?); - } - - if let Some(banner_url) = &self.banner { - let mut image = Image::new(); - image.set_url(banner_url.to_owned()); - group.set_image(image.into_any_base()?); - } - - let mut ap_actor = ApActor::new(self.get_inbox_url()?, group); - ap_actor - .set_preferred_username(self.title.to_owned()) - .set_outbox(self.get_outbox_url()?) - .set_followers(self.get_followers_url()?) - .set_endpoints(Endpoints { - shared_inbox: Some(self.get_shared_inbox_url()?), - ..Default::default() - }); - - let nsfw = self.nsfw; - let category_id = self.category_id; - let group_extension = blocking(pool, move |conn| { - GroupExtension::new(conn, category_id, nsfw) - }) - .await??; - - Ok(Ext2::new( - ap_actor, - group_extension, - self.get_public_key_ext()?, - )) - } - - fn to_tombstone(&self) -> Result { - create_tombstone(self.deleted, &self.actor_id, self.updated, GroupType::Group) - } -} - -#[async_trait::async_trait(?Send)] -impl ActorType for Community { - fn actor_id_str(&self) -> String { - self.actor_id.to_owned() - } - - fn public_key(&self) -> Option { - self.public_key.to_owned() - } - fn private_key(&self) -> Option { - self.private_key.to_owned() - } - - /// As a local community, accept the follow request from a remote user. - async fn send_accept_follow( - &self, - follow: Follow, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let actor_uri = follow - .actor()? - .as_single_xsd_any_uri() - .context(location_info!())?; - let actor = get_or_fetch_and_upsert_actor(actor_uri, context).await?; - - let mut accept = Accept::new(self.actor_id.to_owned(), follow.into_any_base()?); - let to = actor.get_inbox_url()?; - accept - .set_context(activitystreams::context()) - .set_id(generate_activity_id(AcceptType::Accept)?) - .set_to(to.clone()); - - send_activity_single_dest(accept, self, to, context).await?; - Ok(()) - } - - async fn send_delete(&self, creator: &User_, context: &LemmyContext) -> Result<(), LemmyError> { - let group = self.to_apub(context.pool()).await?; - - let mut delete = Delete::new(creator.actor_id.to_owned(), group.into_any_base()?); - delete - .set_context(activitystreams::context()) - .set_id(generate_activity_id(DeleteType::Delete)?) - .set_to(public()) - .set_many_ccs(vec![self.get_followers_url()?]); - - send_to_community_followers(delete, self, context, None).await?; - Ok(()) - } - - async fn send_undo_delete( - &self, - creator: &User_, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let group = self.to_apub(context.pool()).await?; - - let mut delete = Delete::new(creator.actor_id.to_owned(), group.into_any_base()?); - delete - .set_context(activitystreams::context()) - .set_id(generate_activity_id(DeleteType::Delete)?) - .set_to(public()) - .set_many_ccs(vec![self.get_followers_url()?]); - - let mut undo = Undo::new(creator.actor_id.to_owned(), delete.into_any_base()?); - undo - .set_context(activitystreams::context()) - .set_id(generate_activity_id(UndoType::Undo)?) - .set_to(public()) - .set_many_ccs(vec![self.get_followers_url()?]); - - send_to_community_followers(undo, self, context, None).await?; - Ok(()) - } - - async fn send_remove(&self, mod_: &User_, context: &LemmyContext) -> Result<(), LemmyError> { - let group = self.to_apub(context.pool()).await?; - - let mut remove = Remove::new(mod_.actor_id.to_owned(), group.into_any_base()?); - remove - .set_context(activitystreams::context()) - .set_id(generate_activity_id(RemoveType::Remove)?) - .set_to(public()) - .set_many_ccs(vec![self.get_followers_url()?]); - - send_to_community_followers(remove, self, context, None).await?; - Ok(()) - } - - async fn send_undo_remove(&self, mod_: &User_, context: &LemmyContext) -> Result<(), LemmyError> { - let group = self.to_apub(context.pool()).await?; - - let mut remove = Remove::new(mod_.actor_id.to_owned(), group.into_any_base()?); - remove - .set_context(activitystreams::context()) - .set_id(generate_activity_id(RemoveType::Remove)?) - .set_to(public()) - .set_many_ccs(vec![self.get_followers_url()?]); - - // Undo that fake activity - let mut undo = Undo::new(mod_.actor_id.to_owned(), remove.into_any_base()?); - undo - .set_context(activitystreams::context()) - .set_id(generate_activity_id(LikeType::Like)?) - .set_to(public()) - .set_many_ccs(vec![self.get_followers_url()?]); - - send_to_community_followers(undo, self, context, None).await?; - Ok(()) - } - - /// For a given community, returns the inboxes of all followers. - /// - /// TODO: this function is very badly implemented, we should just store shared_inbox_url in - /// CommunityFollowerView - async fn get_follower_inboxes(&self, pool: &DbPool) -> Result, LemmyError> { - let id = self.id; - - let inboxes = blocking(pool, move |conn| { - CommunityFollowerView::for_community(conn, id) - }) - .await??; - let inboxes = inboxes - .into_iter() - .filter(|i| !i.user_local) - .map(|u| -> Result { - let url = Url::parse(&u.user_actor_id)?; - let domain = url.domain().context(location_info!())?; - let port = if let Some(port) = url.port() { - format!(":{}", port) - } else { - "".to_string() - }; - Ok(Url::parse(&format!( - "{}://{}{}/inbox", - Settings::get().get_protocol_string(), - domain, - port, - ))?) - }) - .filter_map(Result::ok) - // Don't send to blocked instances - .filter(|inbox| check_is_apub_id_valid(inbox).is_ok()) - .unique() - .collect(); - - Ok(inboxes) - } - - async fn send_follow( - &self, - _follow_actor_id: &Url, - _context: &LemmyContext, - ) -> Result<(), LemmyError> { - unimplemented!() - } - - async fn send_unfollow( - &self, - _follow_actor_id: &Url, - _context: &LemmyContext, - ) -> Result<(), LemmyError> { - unimplemented!() - } - - fn user_id(&self) -> i32 { - self.creator_id - } -} - -#[async_trait::async_trait(?Send)] -impl FromApub for CommunityForm { - type ApubType = GroupExt; - - /// Parse an ActivityPub group received from another instance into a Lemmy community. - async fn from_apub( - group: &GroupExt, - context: &LemmyContext, - expected_domain: Option, - ) -> Result { - let creator_and_moderator_uris = group.inner.attributed_to().context(location_info!())?; - let creator_uri = creator_and_moderator_uris - .as_many() - .context(location_info!())? - .iter() - .next() - .context(location_info!())? - .as_xsd_any_uri() - .context(location_info!())?; - - let creator = get_or_fetch_and_upsert_user(creator_uri, context).await?; - let name = group - .inner - .name() - .context(location_info!())? - .as_one() - .context(location_info!())? - .as_xsd_string() - .context(location_info!())? - .to_string(); - let title = group - .inner - .preferred_username() - .context(location_info!())? - .to_string(); - // TODO: should be parsed as html and tags like