diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts index b3b52ee0b..3e834dbe4 100644 --- a/api_tests/src/post.spec.ts +++ b/api_tests/src/post.spec.ts @@ -27,6 +27,9 @@ import { reportPost, listPostReports, randomString, + registerUser, + API, + getSite } from './shared'; import { PostView, CommunityView } from 'lemmy-js-client'; @@ -297,29 +300,46 @@ test('A and G subscribe to B (center) A posts, it gets announced to G', async () }); test('Enforce site ban for federated user', async () => { - let alphaShortname = `@lemmy_alpha@lemmy-alpha:8541`; - let alphaPerson = (await resolvePerson(beta, alphaShortname)).person; + // create a test user + let alphaUserJwt = await registerUser(alpha); + expect(alphaUserJwt).toBeDefined(); + let alphaUser: API = { + client: alpha.client, + auth: alphaUserJwt.jwt, + }; + let alphaUserActorId = (await getSite(alphaUser)).my_user.local_user_view.person.actor_id; + expect(alphaUserActorId).toBeDefined(); + let alphaPerson = (await resolvePerson(alphaUser, alphaUserActorId)).person; expect(alphaPerson).toBeDefined(); - // ban alpha from beta site - let banAlpha = await banPersonFromSite(beta, alphaPerson.person.id, true); + // alpha makes post in beta community, it federates to beta instance + let postRes1 = await createPost(alphaUser, betaCommunity.community.id); + let searchBeta1 = await searchPostLocal(beta, postRes1.post_view.post); + expect(searchBeta1.posts[0]).toBeDefined(); + + // ban alpha from its instance + let banAlpha = await banPersonFromSite(alpha, alphaPerson.person.id, true, true); expect(banAlpha.banned).toBe(true); - // Alpha makes post on beta - let postRes = await createPost(alpha, betaCommunity.community.id); - expect(postRes.post_view.post).toBeDefined(); - expect(postRes.post_view.community.local).toBe(false); - expect(postRes.post_view.creator.local).toBe(true); - expect(postRes.post_view.counts.score).toBe(1); + // alpha ban should be federated to beta + let alphaUserOnBeta1 = await resolvePerson(beta, alphaUserActorId); + expect(alphaUserOnBeta1.person.person.banned).toBe(true); - // Make sure that post doesn't make it to beta - let searchBeta = await searchPostLocal(beta, postRes.post_view.post); - let betaPost = searchBeta.posts[0]; - expect(betaPost).toBeUndefined(); + // existing alpha post should be removed on beta + let searchBeta2 = await searchPostLocal(beta, postRes1.post_view.post); + expect(searchBeta2.posts[0]).toBeUndefined(); // Unban alpha - let unBanAlpha = await banPersonFromSite(beta, alphaPerson.person.id, false); + let unBanAlpha = await banPersonFromSite(alpha, alphaPerson.person.id, false, false); expect(unBanAlpha.banned).toBe(false); + + // alpha makes new post in beta community, it federates + let postRes2 = await createPost(alphaUser, betaCommunity.community.id); + let searchBeta3 = await searchPostLocal(beta, postRes2.post_view.post); + expect(searchBeta3.posts[0]).toBeDefined(); + + let alphaUserOnBeta2 = await resolvePerson(beta, alphaUserActorId) + expect(alphaUserOnBeta2.person.person.banned).toBe(false); }); test('Enforce community ban for federated user', async () => { @@ -327,33 +347,41 @@ test('Enforce community ban for federated user', async () => { let alphaPerson = (await resolvePerson(beta, alphaShortname)).person; expect(alphaPerson).toBeDefined(); - // ban alpha from beta site - await banPersonFromCommunity(beta, alphaPerson.person.id, 2, false); - let banAlpha = await banPersonFromCommunity(beta, alphaPerson.person.id, 2, true); + // make a post in beta, it goes through + let postRes1 = await createPost(alpha, betaCommunity.community.id); + let searchBeta1 = await searchPostLocal(beta, postRes1.post_view.post); + expect(searchBeta1.posts[0]).toBeDefined(); + + // ban alpha from beta community + let banAlpha = await banPersonFromCommunity(beta, alphaPerson.person.id, 2, true, true); expect(banAlpha.banned).toBe(true); + // ensure that the post by alpha got removed + let searchAlpha1 = await searchPostLocal(alpha, postRes1.post_view.post); + expect(searchAlpha1.posts[0]).toBeUndefined(); + // Alpha tries to make post on beta, but it fails because of ban - let postRes = await createPost(alpha, betaCommunity.community.id); - expect(postRes.post_view).toBeUndefined(); + let postRes2 = await createPost(alpha, betaCommunity.community.id); + expect(postRes2.post_view).toBeUndefined(); // Unban alpha let unBanAlpha = await banPersonFromCommunity( beta, alphaPerson.person.id, 2, + false, false ); expect(unBanAlpha.banned).toBe(false); - let postRes2 = await createPost(alpha, betaCommunity.community.id); - expect(postRes2.post_view.post).toBeDefined(); - expect(postRes2.post_view.community.local).toBe(false); - expect(postRes2.post_view.creator.local).toBe(true); - expect(postRes2.post_view.counts.score).toBe(1); + let postRes3 = await createPost(alpha, betaCommunity.community.id); + expect(postRes3.post_view.post).toBeDefined(); + expect(postRes3.post_view.community.local).toBe(false); + expect(postRes3.post_view.creator.local).toBe(true); + expect(postRes3.post_view.counts.score).toBe(1); // Make sure that post makes it to beta community - let searchBeta = await searchPostLocal(beta, postRes2.post_view.post); - let betaPost = searchBeta.posts[0]; - expect(betaPost).toBeDefined(); + let searchBeta2 = await searchPostLocal(beta, postRes3.post_view.post); + expect(searchBeta2.posts[0]).toBeDefined(); }); test('Report a post', async () => { diff --git a/api_tests/src/shared.ts b/api_tests/src/shared.ts index 1eec6dcfb..8c4e08ff4 100644 --- a/api_tests/src/shared.ts +++ b/api_tests/src/shared.ts @@ -66,23 +66,23 @@ export interface API { } export let alpha: API = { - client: new LemmyHttp('http://localhost:8541'), + client: new LemmyHttp('http://127.0.0.1:8541'), }; export let beta: API = { - client: new LemmyHttp('http://localhost:8551'), + client: new LemmyHttp('http://127.0.0.1:8551'), }; export let gamma: API = { - client: new LemmyHttp('http://localhost:8561'), + client: new LemmyHttp('http://127.0.0.1:8561'), }; export let delta: API = { - client: new LemmyHttp('http://localhost:8571'), + client: new LemmyHttp('http://127.0.0.1:8571'), }; export let epsilon: API = { - client: new LemmyHttp('http://localhost:8581'), + client: new LemmyHttp('http://127.0.0.1:8581'), }; const password = 'lemmylemmy' @@ -289,13 +289,14 @@ export async function resolvePerson( export async function banPersonFromSite( api: API, person_id: number, - ban: boolean + ban: boolean, + remove_data: boolean, ): Promise { // Make sure lemmy-beta/c/main is cached on lemmy_alpha let form: BanPerson = { person_id, ban, - remove_data: false, + remove_data, auth: api.auth, }; return api.client.banPerson(form); @@ -305,13 +306,13 @@ export async function banPersonFromCommunity( api: API, person_id: number, community_id: number, + remove_data: boolean, ban: boolean ): Promise { - // Make sure lemmy-beta/c/main is cached on lemmy_alpha let form: BanFromCommunity = { person_id, community_id, - remove_data: false, + remove_data, ban, auth: api.auth, }; diff --git a/crates/api/src/community.rs b/crates/api/src/community.rs index a1a44f094..f4f433234 100644 --- a/crates/api/src/community.rs +++ b/crates/api/src/community.rs @@ -8,22 +8,19 @@ use lemmy_api_common::{ community::*, get_local_user_view_from_jwt, is_mod_or_admin, + remove_user_data_in_community, }; use lemmy_apub::{ + activities::block::SiteOrCommunity, objects::{community::ApubCommunity, person::ApubPerson}, protocol::activities::{ - community::{ - add_mod::AddMod, - block_user::BlockUserFromCommunity, - remove_mod::RemoveMod, - undo_block_user::UndoBlockUserFromCommunity, - }, + block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, + community::{add_mod::AddMod, remove_mod::RemoveMod}, following::{follow::FollowCommunity as FollowCommunityApub, undo_follow::UndoFollowCommunity}, }, }; use lemmy_db_schema::{ source::{ - comment::Comment, community::{ Community, CommunityFollower, @@ -43,11 +40,9 @@ use lemmy_db_schema::{ ModTransferCommunityForm, }, person::Person, - post::Post, }, traits::{Bannable, Blockable, Crud, Followable, Joinable}, }; -use lemmy_db_views::comment_view::CommentQueryBuilder; use lemmy_db_views_actor::{ community_moderator_view::CommunityModeratorView, community_view::CommunityView, @@ -213,6 +208,7 @@ impl Perform for BanFromCommunity { let community_id = data.community_id; let banned_person_id = data.person_id; + let remove_data = data.remove_data.unwrap_or(false); let expires = data.expires.map(naive_from_unix); // Verify that only mods or admins can ban @@ -254,10 +250,12 @@ impl Perform for BanFromCommunity { .await? .ok(); - BlockUserFromCommunity::send( - &community, + BlockUser::send( + &SiteOrCommunity::Community(community), &banned_person, &local_user_view.person.clone().into(), + remove_data, + data.reason.clone(), expires, context, ) @@ -268,41 +266,19 @@ impl Perform for BanFromCommunity { .await? .map_err(LemmyError::from) .map_err(|e| e.with_message("community_user_already_banned"))?; - UndoBlockUserFromCommunity::send( - &community, + UndoBlockUser::send( + &SiteOrCommunity::Community(community), &banned_person, &local_user_view.person.clone().into(), + data.reason.clone(), context, ) .await?; } // Remove/Restore their data if that's desired - if data.remove_data.unwrap_or(false) { - // Posts - blocking(context.pool(), move |conn: &'_ _| { - Post::update_removed_for_creator(conn, banned_person_id, Some(community_id), true) - }) - .await??; - - // Comments - // TODO Diesel doesn't allow updates with joins, so this has to be a loop - let comments = blocking(context.pool(), move |conn| { - CommentQueryBuilder::create(conn) - .creator_id(banned_person_id) - .community_id(community_id) - .limit(std::i64::MAX) - .list() - }) - .await??; - - for comment_view in &comments { - let comment_id = comment_view.comment.id; - blocking(context.pool(), move |conn: &'_ _| { - Comment::update_removed(conn, comment_id, true) - }) - .await??; - } + if remove_data { + remove_user_data_in_community(community_id, banned_person_id, context.pool()).await?; } // Mod tables diff --git a/crates/api/src/local_user.rs b/crates/api/src/local_user.rs index 974beb6ea..b4e2bfb19 100644 --- a/crates/api/src/local_user.rs +++ b/crates/api/src/local_user.rs @@ -10,10 +10,15 @@ use lemmy_api_common::{ is_admin, password_length_check, person::*, + remove_user_data, send_email_verification_success, send_password_reset_email, send_verification_email, }; +use lemmy_apub::{ + activities::block::SiteOrCommunity, + protocol::activities::block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, +}; use lemmy_db_schema::{ diesel_option_overwrite, diesel_option_overwrite_to_url, @@ -21,7 +26,6 @@ use lemmy_db_schema::{ naive_now, source::{ comment::Comment, - community::Community, email_verification::EmailVerification, local_user::{LocalUser, LocalUserForm}, moderator::*, @@ -29,7 +33,6 @@ use lemmy_db_schema::{ person::*, person_block::{PersonBlock, PersonBlockForm}, person_mention::*, - post::Post, private_message::PrivateMessage, site::*, }, @@ -44,7 +47,6 @@ use lemmy_db_views::{ private_message_view::PrivateMessageView, }; use lemmy_db_views_actor::{ - community_moderator_view::CommunityModeratorView, person_mention_view::{PersonMentionQueryBuilder, PersonMentionView}, person_view::PersonViewSafe, }; @@ -91,7 +93,7 @@ impl Perform for Login { return Err(LemmyError::from_message("password_incorrect")); } - let site = blocking(context.pool(), Site::read_simple).await??; + let site = blocking(context.pool(), Site::read_local_site).await??; if site.require_email_verification && !local_user_view.local_user.email_verified { return Err(LemmyError::from_message("email_not_verified")); } @@ -200,7 +202,7 @@ impl Perform for SaveUserSettings { // When the site requires email, make sure email is not Some(None). IE, an overwrite to a None value if let Some(email) = &email { - let site_fut = blocking(context.pool(), Site::read_simple); + let site_fut = blocking(context.pool(), Site::read_local_site); if email.is_none() && site_fut.await??.require_email_verification { return Err(LemmyError::from_message("email_required")); } @@ -443,45 +445,15 @@ impl Perform for BanPerson { let expires = data.expires.map(naive_from_unix); let ban_person = move |conn: &'_ _| Person::ban_person(conn, banned_person_id, ban, expires); - blocking(context.pool(), ban_person) + let person = blocking(context.pool(), ban_person) .await? .map_err(LemmyError::from) .map_err(|e| e.with_message("couldnt_update_user"))?; // Remove their data if that's desired - if data.remove_data.unwrap_or(false) { - // Posts - blocking(context.pool(), move |conn: &'_ _| { - Post::update_removed_for_creator(conn, banned_person_id, None, true) - }) - .await??; - - // Communities - // Remove all communities where they're the top mod - // for now, remove the communities manually - let first_mod_communities = blocking(context.pool(), move |conn: &'_ _| { - CommunityModeratorView::get_community_first_mods(conn) - }) - .await??; - - // Filter to only this banned users top communities - let banned_user_first_communities: Vec = first_mod_communities - .into_iter() - .filter(|fmc| fmc.moderator.id == banned_person_id) - .collect(); - - for first_mod_community in banned_user_first_communities { - blocking(context.pool(), move |conn: &'_ _| { - Community::update_removed(conn, first_mod_community.community.id, true) - }) - .await??; - } - - // Comments - blocking(context.pool(), move |conn: &'_ _| { - Comment::update_removed_for_creator(conn, banned_person_id, true) - }) - .await??; + let remove_data = data.remove_data.unwrap_or(false); + if remove_data { + remove_user_data(person.id, context.pool()).await?; } // Mod tables @@ -501,6 +473,36 @@ impl Perform for BanPerson { }) .await??; + let site = SiteOrCommunity::Site( + blocking(context.pool(), Site::read_local_site) + .await?? + .into(), + ); + // if the action affects a local user, federate to other instances + if person.local { + if ban { + BlockUser::send( + &site, + &person.into(), + &local_user_view.person.into(), + remove_data, + data.reason.clone(), + expires, + context, + ) + .await?; + } else { + UndoBlockUser::send( + &site, + &person.into(), + &local_user_view.person.into(), + data.reason.clone(), + context, + ) + .await?; + } + } + let res = BanPersonResponse { person_view, banned: data.ban, diff --git a/crates/api/src/site.rs b/crates/api/src/site.rs index 20ceffe57..92dbdd64d 100644 --- a/crates/api/src/site.rs +++ b/crates/api/src/site.rs @@ -583,7 +583,7 @@ impl Perform for ListRegistrationApplications { is_admin(&local_user_view)?; let unread_only = data.unread_only; - let verified_email_only = blocking(context.pool(), Site::read_simple) + let verified_email_only = blocking(context.pool(), Site::read_local_site) .await?? .require_email_verification; @@ -689,7 +689,7 @@ impl Perform for GetUnreadRegistrationApplicationCount { // Only let admins do this is_admin(&local_user_view)?; - let verified_email_only = blocking(context.pool(), Site::read_simple) + let verified_email_only = blocking(context.pool(), Site::read_local_site) .await?? .require_email_verification; diff --git a/crates/api_common/src/lib.rs b/crates/api_common/src/lib.rs index 2d1f1bf50..bc41b0c95 100644 --- a/crates/api_common/src/lib.rs +++ b/crates/api_common/src/lib.rs @@ -10,6 +10,7 @@ use itertools::Itertools; use lemmy_db_schema::{ newtypes::{CommunityId, LocalUserId, PersonId, PostId}, source::{ + comment::Comment, community::Community, email_verification::{EmailVerification, EmailVerificationForm}, password_reset_request::PasswordResetRequest, @@ -22,8 +23,12 @@ use lemmy_db_schema::{ traits::{ApubActor, Crud, Readable}, DbPool, }; -use lemmy_db_views::local_user_view::{LocalUserSettingsView, LocalUserView}; +use lemmy_db_views::{ + comment_view::CommentQueryBuilder, + local_user_view::{LocalUserSettingsView, LocalUserView}, +}; use lemmy_db_views_actor::{ + community_moderator_view::CommunityModeratorView, community_person_ban_view::CommunityPersonBanView, community_view::CommunityView, }; @@ -35,7 +40,6 @@ use lemmy_utils::{ LemmyError, Sensitive, }; -use url::Url; pub async fn blocking(pool: &DbPool, f: F) -> Result where @@ -267,7 +271,7 @@ pub async fn check_person_block( #[tracing::instrument(skip_all)] pub async fn check_downvotes_enabled(score: i16, pool: &DbPool) -> Result<(), LemmyError> { if score == -1 { - let site = blocking(pool, Site::read_simple).await??; + let site = blocking(pool, Site::read_local_site).await??; if !site.enable_downvotes { return Err(LemmyError::from_message("downvotes_disabled")); } @@ -281,7 +285,7 @@ pub async fn check_private_instance( pool: &DbPool, ) -> Result<(), LemmyError> { if local_user_view.is_none() { - let site = blocking(pool, Site::read_simple).await?; + let site = blocking(pool, Site::read_local_site).await?; // The site might not be set up yet if let Ok(site) = site { @@ -311,7 +315,7 @@ pub async fn build_federated_instances( let mut linked = distinct_communities .iter() - .map(|actor_id| Ok(Url::parse(actor_id)?.host_str().unwrap_or("").to_string())) + .map(|actor_id| Ok(actor_id.host_str().unwrap_or("").to_string())) .collect::, LemmyError>>()?; if let Some(allowed) = allowed.as_ref() { @@ -511,7 +515,7 @@ pub async fn check_private_instance_and_federation_enabled( pool: &DbPool, settings: &Settings, ) -> Result<(), LemmyError> { - let site_opt = blocking(pool, Site::read_simple).await?; + let site_opt = blocking(pool, Site::read_local_site).await?; if let Ok(site) = site_opt { if site.private_instance && settings.federation.enabled { @@ -555,3 +559,73 @@ where Ok(blocking(pool, move |conn| Actor::read_from_name(conn, &identifier)).await??) } } + +pub async fn remove_user_data(banned_person_id: PersonId, pool: &DbPool) -> Result<(), LemmyError> { + // Posts + blocking(pool, move |conn: &'_ _| { + Post::update_removed_for_creator(conn, banned_person_id, None, true) + }) + .await??; + + // Communities + // Remove all communities where they're the top mod + // for now, remove the communities manually + let first_mod_communities = blocking(pool, move |conn: &'_ _| { + CommunityModeratorView::get_community_first_mods(conn) + }) + .await??; + + // Filter to only this banned users top communities + let banned_user_first_communities: Vec = first_mod_communities + .into_iter() + .filter(|fmc| fmc.moderator.id == banned_person_id) + .collect(); + + for first_mod_community in banned_user_first_communities { + blocking(pool, move |conn: &'_ _| { + Community::update_removed(conn, first_mod_community.community.id, true) + }) + .await??; + } + + // Comments + blocking(pool, move |conn: &'_ _| { + Comment::update_removed_for_creator(conn, banned_person_id, true) + }) + .await??; + + Ok(()) +} + +pub async fn remove_user_data_in_community( + community_id: CommunityId, + banned_person_id: PersonId, + pool: &DbPool, +) -> Result<(), LemmyError> { + // Posts + blocking(pool, move |conn| { + Post::update_removed_for_creator(conn, banned_person_id, Some(community_id), true) + }) + .await??; + + // Comments + // TODO Diesel doesn't allow updates with joins, so this has to be a loop + let comments = blocking(pool, move |conn| { + CommentQueryBuilder::create(conn) + .creator_id(banned_person_id) + .community_id(community_id) + .limit(std::i64::MAX) + .list() + }) + .await??; + + for comment_view in &comments { + let comment_id = comment_view.comment.id; + blocking(pool, move |conn| { + Comment::update_removed(conn, comment_id, true) + }) + .await??; + } + + Ok(()) +} diff --git a/crates/api_crud/src/community/create.rs b/crates/api_crud/src/community/create.rs index fb090b864..d78c8ad7a 100644 --- a/crates/api_crud/src/community/create.rs +++ b/crates/api_crud/src/community/create.rs @@ -53,7 +53,7 @@ impl PerformCrud for CreateCommunity { let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?; - let site = blocking(context.pool(), move |conn| Site::read(conn, 0)).await??; + let site = blocking(context.pool(), Site::read_local_site).await??; if site.community_creation_admin_only && is_admin(&local_user_view).is_err() { return Err(LemmyError::from_message( "only_admins_can_create_communities", diff --git a/crates/api_crud/src/site/create.rs b/crates/api_crud/src/site/create.rs index 3de2c3ab8..d714afea7 100644 --- a/crates/api_crud/src/site/create.rs +++ b/crates/api_crud/src/site/create.rs @@ -7,19 +7,25 @@ use lemmy_api_common::{ site::*, site_description_length_check, }; +use lemmy_apub::generate_site_inbox_url; use lemmy_db_schema::{ diesel_option_overwrite, diesel_option_overwrite_to_url, + naive_now, + newtypes::DbUrl, source::site::{Site, SiteForm}, traits::Crud, }; use lemmy_db_views::site_view::SiteView; use lemmy_utils::{ + apub::generate_actor_keypair, + settings::structs::Settings, utils::{check_slurs, check_slurs_opt}, ConnectionId, LemmyError, }; use lemmy_websocket::LemmyContext; +use url::Url; #[async_trait::async_trait(?Send)] impl PerformCrud for CreateSite { @@ -33,7 +39,7 @@ impl PerformCrud for CreateSite { ) -> Result { let data: &CreateSite = self; - let read_site = Site::read_simple; + let read_site = Site::read_local_site; if blocking(context.pool(), read_site).await?.is_ok() { return Err(LemmyError::from_message("site_already_exists")); }; @@ -56,6 +62,9 @@ impl PerformCrud for CreateSite { site_description_length_check(desc)?; } + let actor_id: DbUrl = Url::parse(&Settings::get().get_protocol_and_hostname())?.into(); + let inbox_url = Some(generate_site_inbox_url(&actor_id)?); + let keypair = generate_actor_keypair()?; let site_form = SiteForm { name: data.name.to_owned(), sidebar, @@ -66,6 +75,11 @@ impl PerformCrud for CreateSite { open_registration: data.open_registration, enable_nsfw: data.enable_nsfw, community_creation_admin_only: data.community_creation_admin_only, + actor_id: Some(actor_id), + last_refreshed_at: Some(naive_now()), + inbox_url, + private_key: Some(Some(keypair.private_key)), + public_key: Some(keypair.public_key), ..SiteForm::default() }; diff --git a/crates/api_crud/src/site/update.rs b/crates/api_crud/src/site/update.rs index 0a94062a7..c6f595230 100644 --- a/crates/api_crud/src/site/update.rs +++ b/crates/api_crud/src/site/update.rs @@ -20,6 +20,7 @@ use lemmy_db_schema::{ use lemmy_db_views::site_view::SiteView; use lemmy_utils::{utils::check_slurs_opt, ConnectionId, LemmyError}; use lemmy_websocket::{messages::SendAllMessage, LemmyContext, UserOperationCrud}; +use std::default::Default; #[async_trait::async_trait(?Send)] impl PerformCrud for EditSite { @@ -41,7 +42,7 @@ impl PerformCrud for EditSite { // Make sure user is an admin is_admin(&local_user_view)?; - let found_site = blocking(context.pool(), Site::read_simple).await??; + let found_site = blocking(context.pool(), Site::read_local_site).await??; let sidebar = diesel_option_overwrite(&data.sidebar); let description = diesel_option_overwrite(&data.description); @@ -68,6 +69,7 @@ impl PerformCrud for EditSite { require_application: data.require_application, application_question, private_instance: data.private_instance, + ..SiteForm::default() }; let update_site = blocking(context.pool(), move |conn| { diff --git a/crates/api_crud/src/user/create.rs b/crates/api_crud/src/user/create.rs index 6f8a5fa0e..06b576e51 100644 --- a/crates/api_crud/src/user/create.rs +++ b/crates/api_crud/src/user/create.rs @@ -58,7 +58,7 @@ impl PerformCrud for Register { let (mut email_verification, mut require_application) = (false, false); // Make sure site has open registration - if let Ok(site) = blocking(context.pool(), Site::read_simple).await? { + if let Ok(site) = blocking(context.pool(), Site::read_local_site).await? { if !site.open_registration { return Err(LemmyError::from_message("registration_closed")); } diff --git a/crates/apub/assets/lemmy/activities/community/block_user.json b/crates/apub/assets/lemmy/activities/block/block_user.json similarity index 89% rename from crates/apub/assets/lemmy/activities/community/block_user.json rename to crates/apub/assets/lemmy/activities/block/block_user.json index 4d43e086e..a12b68e7e 100644 --- a/crates/apub/assets/lemmy/activities/community/block_user.json +++ b/crates/apub/assets/lemmy/activities/block/block_user.json @@ -9,6 +9,8 @@ ], "target": "http://enterprise.lemmy.ml/c/main", "type": "Block", + "remove_data": "true", + "summary": "spam post", "expires": "2021-11-01T12:23:50.151874+00:00", "id": "http://enterprise.lemmy.ml/activities/block/5d42fffb-0903-4625-86d4-0b39bb344fc2" } diff --git a/crates/apub/assets/lemmy/activities/community/undo_block_user.json b/crates/apub/assets/lemmy/activities/block/undo_block_user.json similarity index 87% rename from crates/apub/assets/lemmy/activities/community/undo_block_user.json rename to crates/apub/assets/lemmy/activities/block/undo_block_user.json index 5810b4f48..41c9aad77 100644 --- a/crates/apub/assets/lemmy/activities/community/undo_block_user.json +++ b/crates/apub/assets/lemmy/activities/block/undo_block_user.json @@ -14,6 +14,9 @@ ], "target": "http://enterprise.lemmy.ml/c/main", "type": "Block", + "remove_data": "true", + "summary": "spam post", + "expires": "2021-11-01T12:23:50.151874+00:00", "id": "http://enterprise.lemmy.ml/activities/block/726f43ab-bd0e-4ab3-89c8-627e976f553c" }, "cc": [ diff --git a/crates/apub/assets/lemmy/objects/instance.json b/crates/apub/assets/lemmy/objects/instance.json new file mode 100644 index 000000000..73df87759 --- /dev/null +++ b/crates/apub/assets/lemmy/objects/instance.json @@ -0,0 +1,39 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "stickied": "as:stickied", + "pt": "https://join-lemmy.org#", + "sc": "http://schema.org#", + "matrixUserId": { + "type": "sc:Text", + "id": "as:alsoKnownAs" + }, + "sensitive": "as:sensitive", + "comments_enabled": { + "type": "sc:Boolean", + "id": "pt:commentsEnabled" + }, + "moderators": "as:moderators" + }, + "https://w3id.org/security/v1" + ], + "type": "Service", + "id": "https://enterprise.lemmy.ml/", + "name": "Enterprise", + "summary": "A test instance", + "content": "

Enterprise sidebar

\\n", + "mediaType": "text/html", + "source": { + "content": "Enterprise sidebar", + "mediaType": "text/markdown" + }, + "inbox": "https://enterprise.lemmy.ml/inbox", + "outbox": "https://enterprise.lemmy.ml/outbox", + "publicKey": { + "id": "https://enterprise.lemmy.ml/#main-key", + "owner": "https://enterprise.lemmy.ml/", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAupcK0xTw5yQb/fnztAmb\n9LfPbhJJP1+1GwUaOXGYiDJD6uYJhl9CLmgztLl3RyV9ltOYoN8/NLNDfOMmgOjd\nrsNWEjDI9IcVPmiZnhU7hsi6KgQvJzzv8O5/xYjAGhDfrGmtdpL+lyG0B5fQod8J\n/V5VWvTQ0B0qFrLSBBuhOrp8/fTtDskdtElDPtnNfH2jn6FgtLOijidWwf9ekFo4\n0I1JeuEw6LuD/CzKVJTPoztzabUV1DQF/DnFJm+8y7SCJa9jEO56Uf9eVfa1jF6f\ndH6ZvNJMiafstVuLMAw7C/eNJy3ufXgtZ4403oOKA0aRSYf1cc9pHSZ9gDE/mevH\nLwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "published": "2022-01-19T21:52:11.110741+00:00" +} \ No newline at end of file diff --git a/crates/apub/src/activities/block/block_user.rs b/crates/apub/src/activities/block/block_user.rs new file mode 100644 index 000000000..01eb00969 --- /dev/null +++ b/crates/apub/src/activities/block/block_user.rs @@ -0,0 +1,241 @@ +use crate::{ + activities::{ + block::{generate_cc, generate_instance_inboxes, SiteOrCommunity}, + community::{announce::GetCommunity, send_activity_in_community}, + generate_activity_id, + send_lemmy_activity, + verify_activity, + verify_is_public, + verify_mod_action, + verify_person_in_community, + }, + activity_lists::AnnouncableActivities, + objects::{community::ApubCommunity, person::ApubPerson}, + protocol::activities::block::block_user::BlockUser, +}; +use activitystreams_kinds::{activity::BlockType, public}; +use anyhow::anyhow; +use chrono::NaiveDateTime; +use lemmy_api_common::{blocking, remove_user_data, remove_user_data_in_community}; +use lemmy_apub_lib::{ + data::Data, + object_id::ObjectId, + traits::{ActivityHandler, ActorType}, + verify::verify_domains_match, +}; +use lemmy_db_schema::{ + source::{ + community::{ + CommunityFollower, + CommunityFollowerForm, + CommunityPersonBan, + CommunityPersonBanForm, + }, + moderator::{ModBan, ModBanForm}, + person::Person, + }, + traits::{Bannable, Crud, Followable}, +}; +use lemmy_utils::{settings::structs::Settings, utils::convert_datetime, LemmyError}; +use lemmy_websocket::LemmyContext; + +impl BlockUser { + pub(in crate::activities::block) async fn new( + target: &SiteOrCommunity, + user: &ApubPerson, + mod_: &ApubPerson, + remove_data: Option, + reason: Option, + expires: Option, + context: &LemmyContext, + ) -> Result { + Ok(BlockUser { + actor: ObjectId::new(mod_.actor_id()), + to: vec![public()], + object: ObjectId::new(user.actor_id()), + cc: generate_cc(target, context.pool()).await?, + target: target.id(), + kind: BlockType::Block, + remove_data, + summary: reason, + id: generate_activity_id( + BlockType::Block, + &context.settings().get_protocol_and_hostname(), + )?, + expires: expires.map(convert_datetime), + unparsed: Default::default(), + }) + } + + #[tracing::instrument(skip_all)] + pub async fn send( + target: &SiteOrCommunity, + user: &ApubPerson, + mod_: &ApubPerson, + remove_data: bool, + reason: Option, + expires: Option, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let block = BlockUser::new( + target, + user, + mod_, + Some(remove_data), + reason, + expires, + context, + ) + .await?; + let block_id = block.id.clone(); + + match target { + SiteOrCommunity::Site(_) => { + let inboxes = generate_instance_inboxes(user, context.pool()).await?; + send_lemmy_activity(context, &block, &block_id, mod_, inboxes, false).await + } + SiteOrCommunity::Community(c) => { + let activity = AnnouncableActivities::BlockUser(block); + let inboxes = vec![user.shared_inbox_or_inbox_url()]; + send_activity_in_community(activity, &block_id, mod_, c, inboxes, context).await + } + } + } +} + +#[async_trait::async_trait(?Send)] +impl ActivityHandler for BlockUser { + type DataType = LemmyContext; + + #[tracing::instrument(skip_all)] + async fn verify( + &self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + verify_is_public(&self.to, &self.cc)?; + verify_activity(&self.id, self.actor.inner(), &context.settings())?; + match self + .target + .dereference(context, context.client(), request_counter) + .await? + { + SiteOrCommunity::Site(site) => { + let domain = self.object.inner().domain().expect("url needs domain"); + if Settings::get().hostname == domain { + return Err( + anyhow!("Site bans from remote instance can't affect user's home instance").into(), + ); + } + // site ban can only target a user who is on the same instance as the actor (admin) + verify_domains_match(&site.actor_id(), self.actor.inner())?; + verify_domains_match(&site.actor_id(), self.object.inner())?; + } + SiteOrCommunity::Community(community) => { + verify_person_in_community(&self.actor, &community, context, request_counter).await?; + verify_mod_action(&self.actor, &community, context, request_counter).await?; + } + } + Ok(()) + } + + #[tracing::instrument(skip_all)] + async fn receive( + self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + let expires = self.expires.map(|u| u.naive_local()); + let mod_person = self + .actor + .dereference(context, context.client(), request_counter) + .await?; + let blocked_person = self + .object + .dereference(context, context.client(), request_counter) + .await?; + let target = self + .target + .dereference(context, context.client(), request_counter) + .await?; + match target { + SiteOrCommunity::Site(_site) => { + let blocked_person = blocking(context.pool(), move |conn| { + Person::ban_person(conn, blocked_person.id, true, expires) + }) + .await??; + if self.remove_data.unwrap_or(false) { + remove_user_data(blocked_person.id, context.pool()).await?; + } + + // write mod log + let form = ModBanForm { + mod_person_id: mod_person.id, + other_person_id: blocked_person.id, + reason: self.summary, + banned: Some(true), + expires, + }; + blocking(context.pool(), move |conn| ModBan::create(conn, &form)).await??; + } + SiteOrCommunity::Community(community) => { + let community_user_ban_form = CommunityPersonBanForm { + community_id: community.id, + person_id: blocked_person.id, + expires: Some(expires), + }; + blocking(context.pool(), move |conn| { + CommunityPersonBan::ban(conn, &community_user_ban_form) + }) + .await??; + + // Also unsubscribe them from the community, if they are subscribed + let community_follower_form = CommunityFollowerForm { + community_id: community.id, + person_id: blocked_person.id, + pending: false, + }; + blocking(context.pool(), move |conn: &'_ _| { + CommunityFollower::unfollow(conn, &community_follower_form) + }) + .await? + .ok(); + + if self.remove_data.unwrap_or(false) { + remove_user_data_in_community(community.id, blocked_person.id, context.pool()).await?; + } + + // write to mod log + let form = ModBanForm { + mod_person_id: mod_person.id, + other_person_id: blocked_person.id, + reason: self.summary, + banned: Some(true), + expires, + }; + blocking(context.pool(), move |conn| ModBan::create(conn, &form)).await??; + } + } + + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl GetCommunity for BlockUser { + #[tracing::instrument(skip_all)] + async fn get_community( + &self, + context: &LemmyContext, + request_counter: &mut i32, + ) -> Result { + let target = self + .target + .dereference(context, context.client(), request_counter) + .await?; + match target { + SiteOrCommunity::Community(c) => Ok(c), + SiteOrCommunity::Site(_) => Err(anyhow!("Calling get_community() on site activity").into()), + } + } +} diff --git a/crates/apub/src/activities/block/mod.rs b/crates/apub/src/activities/block/mod.rs new file mode 100644 index 000000000..7460acec8 --- /dev/null +++ b/crates/apub/src/activities/block/mod.rs @@ -0,0 +1,144 @@ +use crate::{ + objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson}, + protocol::objects::{group::Group, instance::Instance}, +}; +use chrono::NaiveDateTime; +use lemmy_api_common::blocking; +use lemmy_apub_lib::{ + object_id::ObjectId, + traits::{ActorType, ApubObject}, +}; +use lemmy_db_schema::{source::site::Site, DbPool}; +use lemmy_utils::LemmyError; +use lemmy_websocket::LemmyContext; +use serde::Deserialize; +use url::Url; + +pub mod block_user; +pub mod undo_block_user; + +#[derive(Clone, Debug)] +pub enum SiteOrCommunity { + Site(ApubSite), + Community(ApubCommunity), +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum InstanceOrGroup { + Instance(Instance), + Group(Group), +} + +#[async_trait::async_trait(?Send)] +impl ApubObject for SiteOrCommunity { + type DataType = LemmyContext; + type ApubType = InstanceOrGroup; + type TombstoneType = (); + + #[tracing::instrument(skip_all)] + fn last_refreshed_at(&self) -> Option { + Some(match self { + SiteOrCommunity::Site(i) => i.last_refreshed_at, + SiteOrCommunity::Community(c) => c.last_refreshed_at, + }) + } + + #[tracing::instrument(skip_all)] + async fn read_from_apub_id( + object_id: Url, + data: &Self::DataType, + ) -> Result, LemmyError> + where + Self: Sized, + { + let site = ApubSite::read_from_apub_id(object_id.clone(), data).await?; + Ok(match site { + Some(o) => Some(SiteOrCommunity::Site(o)), + None => ApubCommunity::read_from_apub_id(object_id, data) + .await? + .map(SiteOrCommunity::Community), + }) + } + + async fn delete(self, _data: &Self::DataType) -> Result<(), LemmyError> { + unimplemented!() + } + + async fn into_apub(self, _data: &Self::DataType) -> Result { + unimplemented!() + } + + fn to_tombstone(&self) -> Result { + unimplemented!() + } + + #[tracing::instrument(skip_all)] + async fn verify( + apub: &Self::ApubType, + expected_domain: &Url, + data: &Self::DataType, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + match apub { + InstanceOrGroup::Instance(i) => { + ApubSite::verify(i, expected_domain, data, request_counter).await + } + InstanceOrGroup::Group(g) => { + ApubCommunity::verify(g, expected_domain, data, request_counter).await + } + } + } + + #[tracing::instrument(skip_all)] + async fn from_apub( + apub: Self::ApubType, + data: &Self::DataType, + request_counter: &mut i32, + ) -> Result + where + Self: Sized, + { + Ok(match apub { + InstanceOrGroup::Instance(p) => { + SiteOrCommunity::Site(ApubSite::from_apub(p, data, request_counter).await?) + } + InstanceOrGroup::Group(n) => { + SiteOrCommunity::Community(ApubCommunity::from_apub(n, data, request_counter).await?) + } + }) + } +} + +impl SiteOrCommunity { + fn id(&self) -> ObjectId { + match self { + SiteOrCommunity::Site(s) => ObjectId::new(s.actor_id.clone()), + SiteOrCommunity::Community(c) => ObjectId::new(c.actor_id.clone()), + } + } +} + +async fn generate_cc(target: &SiteOrCommunity, pool: &DbPool) -> Result, LemmyError> { + Ok(match target { + SiteOrCommunity::Site(_) => blocking(pool, Site::read_remote_sites) + .await?? + .into_iter() + .map(|s| s.actor_id.into()) + .collect(), + SiteOrCommunity::Community(c) => vec![c.actor_id()], + }) +} + +async fn generate_instance_inboxes( + blocked_user: &ApubPerson, + pool: &DbPool, +) -> Result, LemmyError> { + let mut inboxes: Vec = blocking(pool, Site::read_remote_sites) + .await?? + .into_iter() + .map(|s| s.inbox_url.into()) + .collect(); + inboxes.push(blocked_user.shared_inbox_or_inbox_url()); + Ok(inboxes) +} diff --git a/crates/apub/src/activities/block/undo_block_user.rs b/crates/apub/src/activities/block/undo_block_user.rs new file mode 100644 index 000000000..014b01fd8 --- /dev/null +++ b/crates/apub/src/activities/block/undo_block_user.rs @@ -0,0 +1,164 @@ +use crate::{ + activities::{ + block::{generate_cc, generate_instance_inboxes, SiteOrCommunity}, + community::{announce::GetCommunity, send_activity_in_community}, + generate_activity_id, + send_lemmy_activity, + verify_activity, + verify_is_public, + }, + activity_lists::AnnouncableActivities, + objects::{community::ApubCommunity, person::ApubPerson}, + protocol::activities::block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, +}; +use activitystreams_kinds::{activity::UndoType, public}; +use lemmy_api_common::blocking; +use lemmy_apub_lib::{ + data::Data, + object_id::ObjectId, + traits::{ActivityHandler, ActorType}, + verify::verify_domains_match, +}; +use lemmy_db_schema::{ + source::{ + community::{CommunityPersonBan, CommunityPersonBanForm}, + moderator::{ModBan, ModBanForm}, + person::Person, + }, + traits::{Bannable, Crud}, +}; +use lemmy_utils::LemmyError; +use lemmy_websocket::LemmyContext; + +impl UndoBlockUser { + #[tracing::instrument(skip_all)] + pub async fn send( + target: &SiteOrCommunity, + user: &ApubPerson, + mod_: &ApubPerson, + reason: Option, + context: &LemmyContext, + ) -> Result<(), LemmyError> { + let block = BlockUser::new(target, user, mod_, None, reason, None, context).await?; + + let id = generate_activity_id( + UndoType::Undo, + &context.settings().get_protocol_and_hostname(), + )?; + let undo = UndoBlockUser { + actor: ObjectId::new(mod_.actor_id()), + to: vec![public()], + object: block, + cc: generate_cc(target, context.pool()).await?, + kind: UndoType::Undo, + id: id.clone(), + unparsed: Default::default(), + }; + + let inboxes = vec![user.shared_inbox_or_inbox_url()]; + match target { + SiteOrCommunity::Site(_) => { + let inboxes = generate_instance_inboxes(user, context.pool()).await?; + send_lemmy_activity(context, &undo, &id, mod_, inboxes, false).await + } + SiteOrCommunity::Community(c) => { + let activity = AnnouncableActivities::UndoBlockUser(undo); + send_activity_in_community(activity, &id, mod_, c, inboxes, context).await + } + } + } +} + +#[async_trait::async_trait(?Send)] +impl ActivityHandler for UndoBlockUser { + type DataType = LemmyContext; + + #[tracing::instrument(skip_all)] + async fn verify( + &self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + verify_is_public(&self.to, &self.cc)?; + verify_activity(&self.id, self.actor.inner(), &context.settings())?; + verify_domains_match(self.actor.inner(), self.object.actor.inner())?; + self.object.verify(context, request_counter).await?; + Ok(()) + } + + #[tracing::instrument(skip_all)] + async fn receive( + self, + context: &Data, + request_counter: &mut i32, + ) -> Result<(), LemmyError> { + let expires = self.object.expires.map(|u| u.naive_local()); + let mod_person = self + .actor + .dereference(context, context.client(), request_counter) + .await?; + let blocked_person = self + .object + .object + .dereference(context, context.client(), request_counter) + .await?; + match self + .object + .target + .dereference(context, context.client(), request_counter) + .await? + { + SiteOrCommunity::Site(_site) => { + let blocked_person = blocking(context.pool(), move |conn| { + Person::ban_person(conn, blocked_person.id, false, expires) + }) + .await??; + + // write mod log + let form = ModBanForm { + mod_person_id: mod_person.id, + other_person_id: blocked_person.id, + reason: self.object.summary, + banned: Some(false), + expires, + }; + blocking(context.pool(), move |conn| ModBan::create(conn, &form)).await??; + } + SiteOrCommunity::Community(community) => { + let community_user_ban_form = CommunityPersonBanForm { + community_id: community.id, + person_id: blocked_person.id, + expires: None, + }; + blocking(context.pool(), move |conn: &'_ _| { + CommunityPersonBan::unban(conn, &community_user_ban_form) + }) + .await??; + + // write to mod log + let form = ModBanForm { + mod_person_id: mod_person.id, + other_person_id: blocked_person.id, + reason: self.object.summary, + banned: Some(false), + expires, + }; + blocking(context.pool(), move |conn| ModBan::create(conn, &form)).await??; + } + } + + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl GetCommunity for UndoBlockUser { + #[tracing::instrument(skip_all)] + async fn get_community( + &self, + context: &LemmyContext, + request_counter: &mut i32, + ) -> Result { + self.object.get_community(context, request_counter).await + } +} diff --git a/crates/apub/src/activities/community/block_user.rs b/crates/apub/src/activities/community/block_user.rs deleted file mode 100644 index f5a6f02ce..000000000 --- a/crates/apub/src/activities/community/block_user.rs +++ /dev/null @@ -1,145 +0,0 @@ -use crate::{ - activities::{ - community::{announce::GetCommunity, send_activity_in_community}, - generate_activity_id, - verify_activity, - verify_is_public, - verify_mod_action, - verify_person_in_community, - }, - activity_lists::AnnouncableActivities, - objects::{community::ApubCommunity, person::ApubPerson}, - protocol::activities::community::block_user::BlockUserFromCommunity, -}; -use activitystreams_kinds::{activity::BlockType, public}; -use chrono::NaiveDateTime; -use lemmy_api_common::blocking; -use lemmy_apub_lib::{ - data::Data, - object_id::ObjectId, - traits::{ActivityHandler, ActorType}, -}; -use lemmy_db_schema::{ - source::community::{ - CommunityFollower, - CommunityFollowerForm, - CommunityPersonBan, - CommunityPersonBanForm, - }, - traits::{Bannable, Followable}, -}; -use lemmy_utils::{utils::convert_datetime, LemmyError}; -use lemmy_websocket::LemmyContext; - -impl BlockUserFromCommunity { - pub(in crate::activities::community) fn new( - community: &ApubCommunity, - target: &ApubPerson, - actor: &ApubPerson, - expires: Option, - context: &LemmyContext, - ) -> Result { - Ok(BlockUserFromCommunity { - actor: ObjectId::new(actor.actor_id()), - to: vec![public()], - object: ObjectId::new(target.actor_id()), - cc: vec![community.actor_id()], - target: ObjectId::new(community.actor_id()), - kind: BlockType::Block, - id: generate_activity_id( - BlockType::Block, - &context.settings().get_protocol_and_hostname(), - )?, - expires: expires.map(convert_datetime), - unparsed: Default::default(), - }) - } - - #[tracing::instrument(skip_all)] - pub async fn send( - community: &ApubCommunity, - target: &ApubPerson, - actor: &ApubPerson, - expires: Option, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let block = BlockUserFromCommunity::new(community, target, actor, expires, context)?; - let block_id = block.id.clone(); - - let activity = AnnouncableActivities::BlockUserFromCommunity(block); - let inboxes = vec![target.shared_inbox_or_inbox_url()]; - send_activity_in_community(activity, &block_id, actor, community, inboxes, context).await - } -} - -#[async_trait::async_trait(?Send)] -impl ActivityHandler for BlockUserFromCommunity { - type DataType = LemmyContext; - - #[tracing::instrument(skip_all)] - async fn verify( - &self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - verify_is_public(&self.to, &self.cc)?; - verify_activity(&self.id, self.actor.inner(), &context.settings())?; - let community = self.get_community(context, request_counter).await?; - verify_person_in_community(&self.actor, &community, context, request_counter).await?; - verify_mod_action(&self.actor, &community, context, request_counter).await?; - Ok(()) - } - - #[tracing::instrument(skip_all)] - async fn receive( - self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - let community = self.get_community(context, request_counter).await?; - let blocked_user = self - .object - .dereference(context, context.client(), request_counter) - .await?; - - let community_user_ban_form = CommunityPersonBanForm { - community_id: community.id, - person_id: blocked_user.id, - expires: Some(self.expires.map(|u| u.naive_local())), - }; - - blocking(context.pool(), move |conn: &'_ _| { - CommunityPersonBan::ban(conn, &community_user_ban_form) - }) - .await??; - - // Also unsubscribe them from the community, if they are subscribed - let community_follower_form = CommunityFollowerForm { - community_id: community.id, - person_id: blocked_user.id, - pending: false, - }; - blocking(context.pool(), move |conn: &'_ _| { - CommunityFollower::unfollow(conn, &community_follower_form) - }) - .await? - .ok(); - - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl GetCommunity for BlockUserFromCommunity { - #[tracing::instrument(skip_all)] - async fn get_community( - &self, - context: &LemmyContext, - request_counter: &mut i32, - ) -> Result { - self - .target - .dereference(context, context.client(), request_counter) - .await - } -} diff --git a/crates/apub/src/activities/community/mod.rs b/crates/apub/src/activities/community/mod.rs index 670b9a7b0..d8d8097eb 100644 --- a/crates/apub/src/activities/community/mod.rs +++ b/crates/apub/src/activities/community/mod.rs @@ -11,10 +11,8 @@ use url::Url; pub mod add_mod; pub mod announce; -pub mod block_user; pub mod remove_mod; pub mod report; -pub mod undo_block_user; pub mod update; #[tracing::instrument(skip_all)] diff --git a/crates/apub/src/activities/community/undo_block_user.rs b/crates/apub/src/activities/community/undo_block_user.rs deleted file mode 100644 index a62315ddd..000000000 --- a/crates/apub/src/activities/community/undo_block_user.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crate::{ - activities::{ - community::{announce::GetCommunity, send_activity_in_community}, - generate_activity_id, - verify_activity, - verify_is_public, - verify_mod_action, - verify_person_in_community, - }, - activity_lists::AnnouncableActivities, - objects::{community::ApubCommunity, person::ApubPerson}, - protocol::activities::community::{ - block_user::BlockUserFromCommunity, - undo_block_user::UndoBlockUserFromCommunity, - }, -}; -use activitystreams_kinds::{activity::UndoType, public}; -use lemmy_api_common::blocking; -use lemmy_apub_lib::{ - data::Data, - object_id::ObjectId, - traits::{ActivityHandler, ActorType}, -}; -use lemmy_db_schema::{ - source::community::{CommunityPersonBan, CommunityPersonBanForm}, - traits::Bannable, -}; -use lemmy_utils::LemmyError; -use lemmy_websocket::LemmyContext; - -impl UndoBlockUserFromCommunity { - #[tracing::instrument(skip_all)] - pub async fn send( - community: &ApubCommunity, - target: &ApubPerson, - actor: &ApubPerson, - context: &LemmyContext, - ) -> Result<(), LemmyError> { - let block = BlockUserFromCommunity::new(community, target, actor, None, context)?; - - let id = generate_activity_id( - UndoType::Undo, - &context.settings().get_protocol_and_hostname(), - )?; - let undo = UndoBlockUserFromCommunity { - actor: ObjectId::new(actor.actor_id()), - to: vec![public()], - object: block, - cc: vec![community.actor_id()], - kind: UndoType::Undo, - id: id.clone(), - unparsed: Default::default(), - }; - - let activity = AnnouncableActivities::UndoBlockUserFromCommunity(undo); - let inboxes = vec![target.shared_inbox_or_inbox_url()]; - send_activity_in_community(activity, &id, actor, community, inboxes, context).await - } -} - -#[async_trait::async_trait(?Send)] -impl ActivityHandler for UndoBlockUserFromCommunity { - type DataType = LemmyContext; - - #[tracing::instrument(skip_all)] - async fn verify( - &self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - verify_is_public(&self.to, &self.cc)?; - verify_activity(&self.id, self.actor.inner(), &context.settings())?; - let community = self.get_community(context, request_counter).await?; - verify_person_in_community(&self.actor, &community, context, request_counter).await?; - verify_mod_action(&self.actor, &community, context, request_counter).await?; - self.object.verify(context, request_counter).await?; - Ok(()) - } - - #[tracing::instrument(skip_all)] - async fn receive( - self, - context: &Data, - request_counter: &mut i32, - ) -> Result<(), LemmyError> { - let community = self.get_community(context, request_counter).await?; - let blocked_user = self - .object - .object - .dereference(context, context.client(), request_counter) - .await?; - - let community_user_ban_form = CommunityPersonBanForm { - community_id: community.id, - person_id: blocked_user.id, - expires: None, - }; - - blocking(context.pool(), move |conn: &'_ _| { - CommunityPersonBan::unban(conn, &community_user_ban_form) - }) - .await??; - - Ok(()) - } -} - -#[async_trait::async_trait(?Send)] -impl GetCommunity for UndoBlockUserFromCommunity { - #[tracing::instrument(skip_all)] - async fn get_community( - &self, - context: &LemmyContext, - request_counter: &mut i32, - ) -> Result { - self.object.get_community(context, request_counter).await - } -} diff --git a/crates/apub/src/activities/mod.rs b/crates/apub/src/activities/mod.rs index e09d69255..343633c8f 100644 --- a/crates/apub/src/activities/mod.rs +++ b/crates/apub/src/activities/mod.rs @@ -25,6 +25,7 @@ use tracing::info; use url::{ParseError, Url}; use uuid::Uuid; +pub mod block; pub mod comment; pub mod community; pub mod deletion; diff --git a/crates/apub/src/activity_lists.rs b/crates/apub/src/activity_lists.rs index 0512ed62c..c8666b93a 100644 --- a/crates/apub/src/activity_lists.rs +++ b/crates/apub/src/activity_lists.rs @@ -3,13 +3,12 @@ use crate::{ objects::community::ApubCommunity, protocol::{ activities::{ + block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, community::{ add_mod::AddMod, announce::AnnounceActivity, - block_user::BlockUserFromCommunity, remove_mod::RemoveMod, report::Report, - undo_block_user::UndoBlockUserFromCommunity, update::UpdateCommunity, }, create_or_update::{comment::CreateOrUpdateComment, post::CreateOrUpdatePost}, @@ -78,14 +77,22 @@ pub enum AnnouncableActivities { Delete(Delete), UndoDelete(UndoDelete), UpdateCommunity(UpdateCommunity), - BlockUserFromCommunity(BlockUserFromCommunity), - UndoBlockUserFromCommunity(UndoBlockUserFromCommunity), + BlockUser(BlockUser), + UndoBlockUser(UndoBlockUser), AddMod(AddMod), RemoveMod(RemoveMod), // For compatibility with Pleroma/Mastodon (send only) Page(Page), } +#[derive(Clone, Debug, Deserialize, Serialize, ActivityHandler)] +#[serde(untagged)] +#[activity_handler(LemmyContext)] +pub enum SiteInboxActivities { + BlockUser(BlockUser), + UndoBlockUser(UndoBlockUser), +} + #[async_trait::async_trait(?Send)] impl GetCommunity for AnnouncableActivities { #[tracing::instrument(skip(self, context))] @@ -103,8 +110,8 @@ impl GetCommunity for AnnouncableActivities { Delete(a) => a.get_community(context, request_counter).await?, UndoDelete(a) => a.get_community(context, request_counter).await?, UpdateCommunity(a) => a.get_community(context, request_counter).await?, - BlockUserFromCommunity(a) => a.get_community(context, request_counter).await?, - UndoBlockUserFromCommunity(a) => a.get_community(context, request_counter).await?, + BlockUser(a) => a.get_community(context, request_counter).await?, + UndoBlockUser(a) => a.get_community(context, request_counter).await?, AddMod(a) => a.get_community(context, request_counter).await?, RemoveMod(a) => a.get_community(context, request_counter).await?, Page(_) => unimplemented!(), diff --git a/crates/apub/src/collections/community_moderators.rs b/crates/apub/src/collections/community_moderators.rs index 338ed0f5a..72d23d4ac 100644 --- a/crates/apub/src/collections/community_moderators.rs +++ b/crates/apub/src/collections/community_moderators.rs @@ -148,6 +148,7 @@ mod tests { source::{ community::Community, person::{Person, PersonForm}, + site::Site, }, traits::Crud, }; @@ -159,6 +160,7 @@ mod tests { let client = reqwest::Client::new().into(); let manager = create_activity_queue(client); let context = init_context(manager.queue_handle().clone()); + let (new_mod, site) = parse_lemmy_person(&context).await; let community = parse_lemmy_community(&context).await; let community_id = community.id; @@ -174,7 +176,7 @@ mod tests { CommunityModerator::join(&context.pool().get().unwrap(), &community_moderator_form).unwrap(); - let new_mod = parse_lemmy_person(&context).await; + assert_eq!(site.actor_id.to_string(), "https://enterprise.lemmy.ml/"); let json: GroupModerators = file_to_json_object("assets/lemmy/collections/group_moderators.json").unwrap(); @@ -209,5 +211,6 @@ mod tests { community_context.0.id, ) .unwrap(); + Site::delete(&*community_context.1.pool().get().unwrap(), site.id).unwrap(); } } diff --git a/crates/apub/src/fetcher/deletable_apub_object.rs b/crates/apub/src/fetcher/deletable_apub_object.rs new file mode 100644 index 000000000..ccb409e8d --- /dev/null +++ b/crates/apub/src/fetcher/deletable_apub_object.rs @@ -0,0 +1,99 @@ +use crate::fetcher::post_or_comment::PostOrComment; +use lemmy_api_common::blocking; +use lemmy_db_queries::source::{ + comment::Comment_, + community::Community_, + person::Person_, + post::Post_, +}; +use lemmy_db_schema::source::{ + comment::Comment, + community::Community, + person::Person, + post::Post, + site::Site, +}; +use lemmy_utils::LemmyError; +use lemmy_websocket::LemmyContext; + +// TODO: merge this trait with ApubObject (means that db_schema needs to depend on apub_lib) +#[async_trait::async_trait(?Send)] +pub trait DeletableApubObject { + // TODO: pass in tombstone with summary field, to decide between remove/delete + async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError>; +} + +#[async_trait::async_trait(?Send)] +impl DeletableApubObject for Community { + async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { + let id = self.id; + blocking(context.pool(), move |conn| { + Community::update_deleted(conn, id, true) + }) + .await??; + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl DeletableApubObject for Person { + async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { + let id = self.id; + blocking(context.pool(), move |conn| Person::delete_account(conn, id)).await??; + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl DeletableApubObject for Post { + async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { + let id = self.id; + blocking(context.pool(), move |conn| { + Post::update_deleted(conn, id, true) + }) + .await??; + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl DeletableApubObject for Comment { + async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { + let id = self.id; + blocking(context.pool(), move |conn| { + Comment::update_deleted(conn, id, true) + }) + .await??; + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl DeletableApubObject for PostOrComment { + async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { + match self { + PostOrComment::Comment(c) => { + blocking(context.pool(), move |conn| { + Comment::update_deleted(conn, c.id, true) + }) + .await??; + } + PostOrComment::Post(p) => { + blocking(context.pool(), move |conn| { + Post::update_deleted(conn, p.id, true) + }) + .await??; + } + } + + Ok(()) + } +} + +#[async_trait::async_trait(?Send)] +impl DeletableApubObject for Site { + async fn delete(self, _context: &LemmyContext) -> Result<(), LemmyError> { + // not implemented, ignore + Ok(()) + } +} diff --git a/crates/apub/src/http/mod.rs b/crates/apub/src/http/mod.rs index 399793e14..1cf705ae2 100644 --- a/crates/apub/src/http/mod.rs +++ b/crates/apub/src/http/mod.rs @@ -36,6 +36,7 @@ mod community; mod person; mod post; pub mod routes; +pub mod site; #[tracing::instrument(skip_all)] pub async fn shared_inbox( diff --git a/crates/apub/src/http/person.rs b/crates/apub/src/http/person.rs index bc0633d8d..9081d5a55 100644 --- a/crates/apub/src/http/person.rs +++ b/crates/apub/src/http/person.rs @@ -1,6 +1,7 @@ use crate::{ activity_lists::PersonInboxActivities, context::WithContext, + generate_outbox_url, http::{ create_apub_response, create_apub_tombstone_response, @@ -9,7 +10,7 @@ use crate::{ ActivityCommonFields, }, objects::person::ApubPerson, - protocol::collections::person_outbox::PersonOutbox, + protocol::collections::empty_outbox::EmptyOutbox, }; use actix_web::{web, web::Payload, HttpRequest, HttpResponse}; use lemmy_api_common::blocking; @@ -80,6 +81,7 @@ pub(crate) async fn get_apub_person_outbox( Person::read_from_name(conn, &info.user_name) }) .await??; - let outbox = PersonOutbox::new(person).await?; + let outbox_id = generate_outbox_url(&person.actor_id)?.into(); + let outbox = EmptyOutbox::new(outbox_id).await?; Ok(create_apub_response(&outbox)) } diff --git a/crates/apub/src/http/routes.rs b/crates/apub/src/http/routes.rs index a57290e4b..d90bf4002 100644 --- a/crates/apub/src/http/routes.rs +++ b/crates/apub/src/http/routes.rs @@ -11,6 +11,7 @@ use crate::http::{ person::{get_apub_person_http, get_apub_person_outbox, person_inbox}, post::get_apub_post, shared_inbox, + site::{get_apub_site_http, get_apub_site_inbox, get_apub_site_outbox}, }; use actix_web::{ guard::{Guard, GuardContext}, @@ -26,6 +27,8 @@ pub fn config(cfg: &mut web::ServiceConfig, settings: &Settings) { println!("federation enabled, host is {}", settings.hostname); cfg + .route("/", web::get().to(get_apub_site_http)) + .route("/site_outbox", web::get().to(get_apub_site_outbox)) .route( "/c/{community_name}", web::get().to(get_apub_community_http), @@ -57,7 +60,8 @@ pub fn config(cfg: &mut web::ServiceConfig, settings: &Settings) { .guard(InboxRequestGuard) .route("/c/{community_name}/inbox", web::post().to(community_inbox)) .route("/u/{user_name}/inbox", web::post().to(person_inbox)) - .route("/inbox", web::post().to(shared_inbox)), + .route("/inbox", web::post().to(shared_inbox)) + .route("/site_inbox", web::post().to(get_apub_site_inbox)), ); } } diff --git a/crates/apub/src/http/site.rs b/crates/apub/src/http/site.rs new file mode 100644 index 000000000..894622ad6 --- /dev/null +++ b/crates/apub/src/http/site.rs @@ -0,0 +1,49 @@ +use crate::{ + activity_lists::SiteInboxActivities, + context::WithContext, + http::{create_apub_response, payload_to_string, receive_activity, ActivityCommonFields}, + objects::instance::ApubSite, + protocol::collections::empty_outbox::EmptyOutbox, +}; +use actix_web::{web, web::Payload, HttpRequest, HttpResponse}; +use lemmy_api_common::blocking; +use lemmy_apub_lib::traits::ApubObject; +use lemmy_db_schema::source::site::Site; +use lemmy_utils::{settings::structs::Settings, LemmyError}; +use lemmy_websocket::LemmyContext; +use tracing::info; +use url::Url; + +pub(crate) async fn get_apub_site_http( + context: web::Data, +) -> Result { + let site: ApubSite = blocking(context.pool(), Site::read_local_site) + .await?? + .into(); + + let apub = site.into_apub(&context).await?; + Ok(create_apub_response(&apub)) +} + +#[tracing::instrument(skip_all)] +pub(crate) async fn get_apub_site_outbox() -> Result { + let outbox_id = format!( + "{}/site_outbox", + Settings::get().get_protocol_and_hostname() + ); + let outbox = EmptyOutbox::new(Url::parse(&outbox_id)?).await?; + Ok(create_apub_response(&outbox)) +} + +#[tracing::instrument(skip_all)] +pub async fn get_apub_site_inbox( + request: HttpRequest, + payload: Payload, + context: web::Data, +) -> Result { + let unparsed = payload_to_string(payload).await?; + info!("Received site inbox activity {}", unparsed); + let activity_data: ActivityCommonFields = serde_json::from_str(&unparsed)?; + let activity = serde_json::from_str::>(&unparsed)?; + receive_activity(request, activity.inner(), activity_data, &context).await +} diff --git a/crates/apub/src/lib.rs b/crates/apub/src/lib.rs index 94752a397..0652048dc 100644 --- a/crates/apub/src/lib.rs +++ b/crates/apub/src/lib.rs @@ -164,6 +164,12 @@ pub fn generate_inbox_url(actor_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{}/inbox", actor_id))?.into()) } +pub fn generate_site_inbox_url(actor_id: &DbUrl) -> Result { + let mut actor_id: Url = actor_id.clone().into(); + actor_id.set_path("site_inbox"); + Ok(actor_id.into()) +} + pub fn generate_shared_inbox_url(actor_id: &DbUrl) -> Result { let actor_id: Url = actor_id.clone().into(); let url = format!( diff --git a/crates/apub/src/objects/comment.rs b/crates/apub/src/objects/comment.rs index ee131adf7..82d9e4e44 100644 --- a/crates/apub/src/objects/comment.rs +++ b/crates/apub/src/objects/comment.rs @@ -18,7 +18,7 @@ use lemmy_api_common::blocking; use lemmy_apub_lib::{ object_id::ObjectId, traits::ApubObject, - values::{MediaTypeHtml, MediaTypeMarkdown}, + values::MediaTypeHtml, verify::verify_domains_match, }; use lemmy_db_schema::{ @@ -120,10 +120,7 @@ impl ApubObject for ApubComment { cc: maa.ccs, content: markdown_to_html(&self.content), media_type: Some(MediaTypeHtml::Html), - source: SourceCompat::Lemmy(Source { - content: self.content.clone(), - media_type: MediaTypeMarkdown::Markdown, - }), + source: SourceCompat::Lemmy(Source::new(self.content.clone())), in_reply_to, published: Some(convert_datetime(self.published)), updated: self.updated.map(convert_datetime), @@ -213,19 +210,21 @@ pub(crate) mod tests { use super::*; use crate::objects::{ community::{tests::parse_lemmy_community, ApubCommunity}, + instance::ApubSite, person::{tests::parse_lemmy_person, ApubPerson}, post::ApubPost, tests::{file_to_json_object, init_context}, }; use assert_json_diff::assert_json_include; use lemmy_apub_lib::activity_queue::create_activity_queue; + use lemmy_db_schema::source::site::Site; use serial_test::serial; async fn prepare_comment_test( url: &Url, context: &LemmyContext, - ) -> (ApubPerson, ApubCommunity, ApubPost) { - let person = parse_lemmy_person(context).await; + ) -> (ApubPerson, ApubCommunity, ApubPost, ApubSite) { + let (person, site) = parse_lemmy_person(context).await; let community = parse_lemmy_community(context).await; let post_json = file_to_json_object("assets/lemmy/objects/page.json").unwrap(); ApubPost::verify(&post_json, url, context, &mut 0) @@ -234,13 +233,14 @@ pub(crate) mod tests { let post = ApubPost::from_apub(post_json, context, &mut 0) .await .unwrap(); - (person, community, post) + (person, community, post, site) } - fn cleanup(data: (ApubPerson, ApubCommunity, ApubPost), context: &LemmyContext) { + fn cleanup(data: (ApubPerson, ApubCommunity, ApubPost, ApubSite), context: &LemmyContext) { Post::delete(&*context.pool().get().unwrap(), data.2.id).unwrap(); Community::delete(&*context.pool().get().unwrap(), data.1.id).unwrap(); Person::delete(&*context.pool().get().unwrap(), data.0.id).unwrap(); + Site::delete(&*context.pool().get().unwrap(), data.3.id).unwrap(); } #[actix_rt::test] diff --git a/crates/apub/src/objects/community.rs b/crates/apub/src/objects/community.rs index cce61b57f..4a618bf88 100644 --- a/crates/apub/src/objects/community.rs +++ b/crates/apub/src/objects/community.rs @@ -3,6 +3,7 @@ use crate::{ collections::{community_moderators::ApubCommunityModerators, CommunityContext}, generate_moderators_url, generate_outbox_url, + objects::instance::fetch_instance_actor_for_object, protocol::{ objects::{group::Group, tombstone::Tombstone, Endpoints}, ImageObject, @@ -16,7 +17,6 @@ use lemmy_api_common::blocking; use lemmy_apub_lib::{ object_id::ObjectId, traits::{ActorType, ApubObject}, - values::MediaTypeMarkdown, }; use lemmy_db_schema::{source::community::Community, traits::ApubActor}; use lemmy_db_views_actor::community_follower_view::CommunityFollowerView; @@ -80,22 +80,15 @@ impl ApubObject for ApubCommunity { #[tracing::instrument(skip_all)] async fn into_apub(self, _context: &LemmyContext) -> Result { - let source = self.description.clone().map(|bio| Source { - content: bio, - media_type: MediaTypeMarkdown::Markdown, - }); - let icon = self.icon.clone().map(ImageObject::new); - let image = self.banner.clone().map(ImageObject::new); - let group = Group { kind: GroupType::Group, id: ObjectId::new(self.actor_id()), preferred_username: self.name.clone(), name: self.title.clone(), summary: self.description.as_ref().map(|b| markdown_to_html(b)), - source, - icon, - image, + source: self.description.clone().map(Source::new), + icon: self.icon.clone().map(ImageObject::new), + image: self.banner.clone().map(ImageObject::new), sensitive: Some(self.nsfw), moderators: Some(ObjectId::::new( generate_moderators_url(&self.actor_id)?, @@ -160,6 +153,8 @@ impl ApubObject for ApubCommunity { .ok(); } + fetch_instance_actor_for_object(community.actor_id(), context, request_counter).await; + Ok(community) } } @@ -219,9 +214,12 @@ impl ApubCommunity { #[cfg(test)] pub(crate) mod tests { use super::*; - use crate::objects::tests::{file_to_json_object, init_context}; + use crate::objects::{ + instance::tests::parse_lemmy_instance, + tests::{file_to_json_object, init_context}, + }; use lemmy_apub_lib::activity_queue::create_activity_queue; - use lemmy_db_schema::traits::Crud; + use lemmy_db_schema::{source::site::Site, traits::Crud}; use serial_test::serial; pub(crate) async fn parse_lemmy_community(context: &LemmyContext) -> ApubCommunity { @@ -239,7 +237,7 @@ pub(crate) mod tests { let community = ApubCommunity::from_apub(json, context, &mut request_counter) .await .unwrap(); - // this makes two requests to the (intentionally) broken outbox/moderators collections + // this makes one requests to the (intentionally broken) outbox collection assert_eq!(request_counter, 1); community } @@ -250,6 +248,7 @@ pub(crate) mod tests { let client = reqwest::Client::new().into(); let manager = create_activity_queue(client); let context = init_context(manager.queue_handle().clone()); + let site = parse_lemmy_instance(&context).await; let community = parse_lemmy_community(&context).await; assert_eq!(community.title, "Ten Forward"); @@ -257,5 +256,6 @@ pub(crate) mod tests { assert_eq!(community.description.as_ref().unwrap().len(), 132); Community::delete(&*context.pool().get().unwrap(), community.id).unwrap(); + Site::delete(&*context.pool().get().unwrap(), site.id).unwrap(); } } diff --git a/crates/apub/src/objects/instance.rs b/crates/apub/src/objects/instance.rs new file mode 100644 index 000000000..cdd76c5a9 --- /dev/null +++ b/crates/apub/src/objects/instance.rs @@ -0,0 +1,221 @@ +use crate::{ + check_is_apub_id_valid, + objects::get_summary_from_string_or_source, + protocol::{objects::instance::Instance, ImageObject, Source, Unparsed}, +}; +use activitystreams_kinds::actor::ServiceType; +use chrono::NaiveDateTime; +use lemmy_api_common::blocking; +use lemmy_apub_lib::{ + object_id::ObjectId, + traits::{ActorType, ApubObject}, + values::MediaTypeHtml, + verify::verify_domains_match, +}; +use lemmy_db_schema::{ + naive_now, + source::site::{Site, SiteForm}, +}; +use lemmy_utils::{ + utils::{check_slurs, check_slurs_opt, convert_datetime, markdown_to_html}, + LemmyError, +}; +use lemmy_websocket::LemmyContext; +use std::ops::Deref; +use tracing::debug; +use url::Url; + +#[derive(Clone, Debug)] +pub struct ApubSite(Site); + +impl Deref for ApubSite { + type Target = Site; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for ApubSite { + fn from(s: Site) -> Self { + ApubSite { 0: s } + } +} + +#[async_trait::async_trait(?Send)] +impl ApubObject for ApubSite { + type DataType = LemmyContext; + type ApubType = Instance; + type TombstoneType = (); + + fn last_refreshed_at(&self) -> Option { + Some(self.last_refreshed_at) + } + + #[tracing::instrument(skip_all)] + async fn read_from_apub_id( + object_id: Url, + data: &Self::DataType, + ) -> Result, LemmyError> { + Ok( + blocking(data.pool(), move |conn| { + Site::read_from_apub_id(conn, object_id) + }) + .await?? + .map(Into::into), + ) + } + + async fn delete(self, _data: &Self::DataType) -> Result<(), LemmyError> { + unimplemented!() + } + + #[tracing::instrument(skip_all)] + async fn into_apub(self, _data: &Self::DataType) -> Result { + let instance = Instance { + kind: ServiceType::Service, + id: ObjectId::new(self.actor_id()), + name: self.name.clone(), + content: self.sidebar.as_ref().map(|d| markdown_to_html(d)), + source: self.sidebar.clone().map(Source::new), + summary: self.description.clone(), + media_type: self.sidebar.as_ref().map(|_| MediaTypeHtml::Html), + icon: self.icon.clone().map(ImageObject::new), + image: self.banner.clone().map(ImageObject::new), + inbox: self.inbox_url.clone().into(), + outbox: Url::parse(&format!("{}/site_outbox", self.actor_id))?, + public_key: self.get_public_key()?, + published: convert_datetime(self.published), + updated: self.updated.map(convert_datetime), + unparsed: Unparsed::default(), + }; + Ok(instance) + } + + fn to_tombstone(&self) -> Result { + unimplemented!() + } + + #[tracing::instrument(skip_all)] + async fn verify( + apub: &Self::ApubType, + expected_domain: &Url, + data: &Self::DataType, + _request_counter: &mut i32, + ) -> Result<(), LemmyError> { + check_is_apub_id_valid(apub.id.inner(), true, &data.settings())?; + verify_domains_match(expected_domain, apub.id.inner())?; + + let slur_regex = &data.settings().slur_regex(); + check_slurs(&apub.name, slur_regex)?; + check_slurs_opt(&apub.summary, slur_regex)?; + Ok(()) + } + + #[tracing::instrument(skip_all)] + async fn from_apub( + apub: Self::ApubType, + data: &Self::DataType, + _request_counter: &mut i32, + ) -> Result { + let site_form = SiteForm { + name: apub.name.clone(), + sidebar: Some(get_summary_from_string_or_source( + &apub.content, + &apub.source, + )), + updated: apub.updated.map(|u| u.clone().naive_local()), + icon: Some(apub.icon.clone().map(|i| i.url.into())), + banner: Some(apub.image.clone().map(|i| i.url.into())), + description: Some(apub.summary.clone()), + actor_id: Some(apub.id.clone().into()), + last_refreshed_at: Some(naive_now()), + inbox_url: Some(apub.inbox.clone().into()), + public_key: Some(apub.public_key.public_key_pem.clone()), + ..SiteForm::default() + }; + let site = blocking(data.pool(), move |conn| Site::upsert(conn, &site_form)).await??; + Ok(site.into()) + } +} + +impl ActorType for ApubSite { + fn actor_id(&self) -> Url { + self.actor_id.to_owned().into() + } + fn public_key(&self) -> String { + self.public_key.to_owned() + } + fn private_key(&self) -> Option { + self.private_key.to_owned() + } + + fn inbox_url(&self) -> Url { + self.inbox_url.clone().into() + } + + fn shared_inbox_url(&self) -> Option { + None + } +} + +/// Instance actor is at the root path, so we simply need to clear the path and other unnecessary +/// parts of the url. +pub fn instance_actor_id_from_url(mut url: Url) -> Url { + url.set_fragment(None); + url.set_path(""); + url.set_query(None); + url +} + +/// try to fetch the instance actor (to make things like instance rules available) +pub(in crate::objects) async fn fetch_instance_actor_for_object( + object_id: Url, + context: &LemmyContext, + request_counter: &mut i32, +) { + // try to fetch the instance actor (to make things like instance rules available) + let instance_id = instance_actor_id_from_url(object_id); + let site = ObjectId::::new(instance_id.clone()) + .dereference(context, context.client(), request_counter) + .await; + if let Err(e) = site { + debug!("Failed to dereference site for {}: {}", instance_id, e); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::objects::tests::{file_to_json_object, init_context}; + use lemmy_apub_lib::activity_queue::create_activity_queue; + use lemmy_db_schema::traits::Crud; + use serial_test::serial; + + pub(crate) async fn parse_lemmy_instance(context: &LemmyContext) -> ApubSite { + let json: Instance = file_to_json_object("assets/lemmy/objects/instance.json").unwrap(); + let id = Url::parse("https://enterprise.lemmy.ml/").unwrap(); + let mut request_counter = 0; + ApubSite::verify(&json, &id, context, &mut request_counter) + .await + .unwrap(); + let site = ApubSite::from_apub(json, context, &mut request_counter) + .await + .unwrap(); + assert_eq!(request_counter, 0); + site + } + + #[actix_rt::test] + #[serial] + async fn test_parse_lemmy_instance() { + let client = reqwest::Client::new().into(); + let manager = create_activity_queue(client); + let context = init_context(manager.queue_handle().clone()); + let site = parse_lemmy_instance(&context).await; + + assert_eq!(site.name, "Enterprise"); + assert_eq!(site.description.as_ref().unwrap().len(), 15); + + Site::delete(&*context.pool().get().unwrap(), site.id).unwrap(); + } +} diff --git a/crates/apub/src/objects/mod.rs b/crates/apub/src/objects/mod.rs index b5a4760d6..d7e386b10 100644 --- a/crates/apub/src/objects/mod.rs +++ b/crates/apub/src/objects/mod.rs @@ -3,6 +3,7 @@ use html2md::parse_html; pub mod comment; pub mod community; +pub mod instance; pub mod person; pub mod post; pub mod private_message; diff --git a/crates/apub/src/objects/person.rs b/crates/apub/src/objects/person.rs index 21f0f248c..80dd8bfd1 100644 --- a/crates/apub/src/objects/person.rs +++ b/crates/apub/src/objects/person.rs @@ -1,7 +1,7 @@ use crate::{ check_is_apub_id_valid, generate_outbox_url, - objects::get_summary_from_string_or_source, + objects::{get_summary_from_string_or_source, instance::fetch_instance_actor_for_object}, protocol::{ objects::{ person::{Person, UserTypes}, @@ -16,7 +16,6 @@ use lemmy_api_common::blocking; use lemmy_apub_lib::{ object_id::ObjectId, traits::{ActorType, ApubObject}, - values::MediaTypeMarkdown, verify::verify_domains_match, }; use lemmy_db_schema::{ @@ -88,12 +87,6 @@ impl ApubObject for ApubPerson { } else { UserTypes::Person }; - let source = self.bio.clone().map(|bio| Source { - content: bio, - media_type: MediaTypeMarkdown::Markdown, - }); - let icon = self.avatar.clone().map(ImageObject::new); - let image = self.banner.clone().map(ImageObject::new); let person = Person { kind, @@ -101,9 +94,9 @@ impl ApubObject for ApubPerson { preferred_username: self.name.clone(), name: self.display_name.clone(), summary: self.bio.as_ref().map(|b| markdown_to_html(b)), - source, - icon, - image, + source: self.bio.clone().map(Source::new), + icon: self.avatar.clone().map(ImageObject::new), + image: self.banner.clone().map(ImageObject::new), matrix_user_id: self.matrix_user_id.clone(), published: Some(convert_datetime(self.published)), outbox: generate_outbox_url(&self.actor_id)?.into(), @@ -144,7 +137,7 @@ impl ApubObject for ApubPerson { async fn from_apub( person: Person, context: &LemmyContext, - _request_counter: &mut i32, + request_counter: &mut i32, ) -> Result { let person_form = PersonForm { name: person.preferred_username, @@ -175,6 +168,10 @@ impl ApubObject for ApubPerson { DbPerson::upsert(conn, &person_form) }) .await??; + + let actor_id = person.actor_id.clone().into(); + fetch_instance_actor_for_object(actor_id, context, request_counter).await; + Ok(person.into()) } } @@ -204,12 +201,19 @@ impl ActorType for ApubPerson { #[cfg(test)] pub(crate) mod tests { use super::*; - use crate::objects::tests::{file_to_json_object, init_context}; + use crate::{ + objects::{ + instance::{tests::parse_lemmy_instance, ApubSite}, + tests::{file_to_json_object, init_context}, + }, + protocol::objects::instance::Instance, + }; use lemmy_apub_lib::activity_queue::create_activity_queue; - use lemmy_db_schema::traits::Crud; + use lemmy_db_schema::{source::site::Site, traits::Crud}; use serial_test::serial; - pub(crate) async fn parse_lemmy_person(context: &LemmyContext) -> ApubPerson { + pub(crate) async fn parse_lemmy_person(context: &LemmyContext) -> (ApubPerson, ApubSite) { + let site = parse_lemmy_instance(context).await; let json = file_to_json_object("assets/lemmy/objects/person.json").unwrap(); let url = Url::parse("https://enterprise.lemmy.ml/u/picard").unwrap(); let mut request_counter = 0; @@ -220,7 +224,7 @@ pub(crate) mod tests { .await .unwrap(); assert_eq!(request_counter, 0); - person + (person, site) } #[actix_rt::test] @@ -229,13 +233,14 @@ pub(crate) mod tests { let client = reqwest::Client::new().into(); let manager = create_activity_queue(client); let context = init_context(manager.queue_handle().clone()); - let person = parse_lemmy_person(&context).await; + let (person, site) = parse_lemmy_person(&context).await; assert_eq!(person.display_name, Some("Jean-Luc Picard".to_string())); assert!(!person.local); assert_eq!(person.bio.as_ref().unwrap().len(), 39); DbPerson::delete(&*context.pool().get().unwrap(), person.id).unwrap(); + Site::delete(&*context.pool().get().unwrap(), site.id).unwrap(); } #[actix_rt::test] @@ -244,6 +249,16 @@ pub(crate) mod tests { let client = reqwest::Client::new().into(); let manager = create_activity_queue(client); let context = init_context(manager.queue_handle().clone()); + + // create and parse a fake pleroma instance actor, to avoid network request during test + let mut json: Instance = file_to_json_object("assets/lemmy/objects/instance.json").unwrap(); + let id = Url::parse("https://queer.hacktivis.me/").unwrap(); + json.id = ObjectId::new(id); + let mut request_counter = 0; + let site = ApubSite::from_apub(json, &context, &mut request_counter) + .await + .unwrap(); + let json = file_to_json_object("assets/pleroma/objects/person.json").unwrap(); let url = Url::parse("https://queer.hacktivis.me/users/lanodan").unwrap(); let mut request_counter = 0; @@ -261,5 +276,6 @@ pub(crate) mod tests { assert_eq!(person.bio.as_ref().unwrap().len(), 873); DbPerson::delete(&*context.pool().get().unwrap(), person.id).unwrap(); + Site::delete(&*context.pool().get().unwrap(), site.id).unwrap(); } } diff --git a/crates/apub/src/objects/post.rs b/crates/apub/src/objects/post.rs index e4206080a..b15c9374b 100644 --- a/crates/apub/src/objects/post.rs +++ b/crates/apub/src/objects/post.rs @@ -214,6 +214,7 @@ mod tests { tests::{file_to_json_object, init_context}, }; use lemmy_apub_lib::activity_queue::create_activity_queue; + use lemmy_db_schema::source::site::Site; use serial_test::serial; #[actix_rt::test] @@ -222,8 +223,8 @@ mod tests { let client = reqwest::Client::new().into(); let manager = create_activity_queue(client); let context = init_context(manager.queue_handle().clone()); + let (person, site) = parse_lemmy_person(&context).await; let community = parse_lemmy_community(&context).await; - let person = parse_lemmy_person(&context).await; let json = file_to_json_object("assets/lemmy/objects/page.json").unwrap(); let url = Url::parse("https://enterprise.lemmy.ml/post/55143").unwrap(); @@ -246,5 +247,6 @@ mod tests { Post::delete(&*context.pool().get().unwrap(), post.id).unwrap(); Person::delete(&*context.pool().get().unwrap(), person.id).unwrap(); Community::delete(&*context.pool().get().unwrap(), community.id).unwrap(); + Site::delete(&*context.pool().get().unwrap(), site.id).unwrap(); } } diff --git a/crates/apub/src/objects/private_message.rs b/crates/apub/src/objects/private_message.rs index 176ee009e..62af38553 100644 --- a/crates/apub/src/objects/private_message.rs +++ b/crates/apub/src/objects/private_message.rs @@ -8,7 +8,7 @@ use lemmy_api_common::blocking; use lemmy_apub_lib::{ object_id::ObjectId, traits::ApubObject, - values::{MediaTypeHtml, MediaTypeMarkdown}, + values::MediaTypeHtml, verify::verify_domains_match, }; use lemmy_db_schema::{ @@ -87,10 +87,7 @@ impl ApubObject for ApubPrivateMessage { to: [ObjectId::new(recipient.actor_id)], content: markdown_to_html(&self.content), media_type: Some(MediaTypeHtml::Html), - source: Some(Source { - content: self.content.clone(), - media_type: MediaTypeMarkdown::Markdown, - }), + source: Some(Source::new(self.content.clone())), published: Some(convert_datetime(self.published)), updated: self.updated.map(convert_datetime), unparsed: Default::default(), diff --git a/crates/apub/src/protocol/activities/community/block_user.rs b/crates/apub/src/protocol/activities/block/block_user.rs similarity index 65% rename from crates/apub/src/protocol/activities/community/block_user.rs rename to crates/apub/src/protocol/activities/block/block_user.rs index 891fe1f55..5d49fc602 100644 --- a/crates/apub/src/protocol/activities/community/block_user.rs +++ b/crates/apub/src/protocol/activities/block/block_user.rs @@ -1,7 +1,4 @@ -use crate::{ - objects::{community::ApubCommunity, person::ApubPerson}, - protocol::Unparsed, -}; +use crate::{activities::block::SiteOrCommunity, objects::person::ApubPerson, protocol::Unparsed}; use activitystreams_kinds::activity::BlockType; use chrono::{DateTime, FixedOffset}; use lemmy_apub_lib::object_id::ObjectId; @@ -10,16 +7,21 @@ use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct BlockUserFromCommunity { +pub struct BlockUser { pub(crate) actor: ObjectId, #[serde(deserialize_with = "crate::deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: ObjectId, #[serde(deserialize_with = "crate::deserialize_one_or_many")] pub(crate) cc: Vec, - pub(crate) target: ObjectId, + pub(crate) target: ObjectId, #[serde(rename = "type")] pub(crate) kind: BlockType, + /// Quick and dirty solution. + /// TODO: send a separate Delete activity instead + pub(crate) remove_data: Option, + /// block reason, written to mod log + pub(crate) summary: Option, pub(crate) id: Url, #[serde(flatten)] pub(crate) unparsed: Unparsed, diff --git a/crates/apub/src/protocol/activities/block/mod.rs b/crates/apub/src/protocol/activities/block/mod.rs new file mode 100644 index 000000000..eb3736f76 --- /dev/null +++ b/crates/apub/src/protocol/activities/block/mod.rs @@ -0,0 +1,17 @@ +pub mod block_user; +pub mod undo_block_user; + +#[cfg(test)] +mod tests { + use crate::protocol::{ + activities::block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, + tests::test_parse_lemmy_item, + }; + + #[actix_rt::test] + async fn test_parse_lemmy_block() { + test_parse_lemmy_item::("assets/lemmy/activities/block/block_user.json").unwrap(); + test_parse_lemmy_item::("assets/lemmy/activities/block/undo_block_user.json") + .unwrap(); + } +} diff --git a/crates/apub/src/protocol/activities/community/undo_block_user.rs b/crates/apub/src/protocol/activities/block/undo_block_user.rs similarity index 78% rename from crates/apub/src/protocol/activities/community/undo_block_user.rs rename to crates/apub/src/protocol/activities/block/undo_block_user.rs index 02218367b..d3db580e4 100644 --- a/crates/apub/src/protocol/activities/community/undo_block_user.rs +++ b/crates/apub/src/protocol/activities/block/undo_block_user.rs @@ -1,6 +1,6 @@ use crate::{ objects::person::ApubPerson, - protocol::{activities::community::block_user::BlockUserFromCommunity, Unparsed}, + protocol::{activities::block::block_user::BlockUser, Unparsed}, }; use activitystreams_kinds::activity::UndoType; use lemmy_apub_lib::object_id::ObjectId; @@ -9,11 +9,11 @@ use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct UndoBlockUserFromCommunity { +pub struct UndoBlockUser { pub(crate) actor: ObjectId, #[serde(deserialize_with = "crate::deserialize_one_or_many")] pub(crate) to: Vec, - pub(crate) object: BlockUserFromCommunity, + pub(crate) object: BlockUser, #[serde(deserialize_with = "crate::deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] diff --git a/crates/apub/src/protocol/activities/community/mod.rs b/crates/apub/src/protocol/activities/community/mod.rs index 56332a877..a25d5ca2d 100644 --- a/crates/apub/src/protocol/activities/community/mod.rs +++ b/crates/apub/src/protocol/activities/community/mod.rs @@ -1,9 +1,7 @@ pub mod add_mod; pub mod announce; -pub mod block_user; pub mod remove_mod; pub mod report; -pub mod undo_block_user; pub mod update; #[cfg(test)] @@ -12,10 +10,8 @@ mod tests { activities::community::{ add_mod::AddMod, announce::AnnounceActivity, - block_user::BlockUserFromCommunity, remove_mod::RemoveMod, report::Report, - undo_block_user::UndoBlockUserFromCommunity, update::UpdateCommunity, }, tests::test_parse_lemmy_item, @@ -32,15 +28,6 @@ mod tests { test_parse_lemmy_item::("assets/lemmy/activities/community/remove_mod.json") .unwrap(); - test_parse_lemmy_item::( - "assets/lemmy/activities/community/block_user.json", - ) - .unwrap(); - test_parse_lemmy_item::( - "assets/lemmy/activities/community/undo_block_user.json", - ) - .unwrap(); - test_parse_lemmy_item::( "assets/lemmy/activities/community/update_community.json", ) diff --git a/crates/apub/src/protocol/activities/mod.rs b/crates/apub/src/protocol/activities/mod.rs index 2aa488f57..47b01b241 100644 --- a/crates/apub/src/protocol/activities/mod.rs +++ b/crates/apub/src/protocol/activities/mod.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use strum_macros::Display; +pub mod block; pub mod community; pub mod create_or_update; pub mod deletion; diff --git a/crates/apub/src/protocol/collections/person_outbox.rs b/crates/apub/src/protocol/collections/empty_outbox.rs similarity index 60% rename from crates/apub/src/protocol/collections/person_outbox.rs rename to crates/apub/src/protocol/collections/empty_outbox.rs index e616794c6..265575af4 100644 --- a/crates/apub/src/protocol/collections/person_outbox.rs +++ b/crates/apub/src/protocol/collections/empty_outbox.rs @@ -1,24 +1,23 @@ -use crate::generate_outbox_url; use activitystreams_kinds::collection::OrderedCollectionType; -use lemmy_db_schema::source::person::Person; use lemmy_utils::LemmyError; use serde::{Deserialize, Serialize}; use url::Url; +/// Empty placeholder outbox used for Person, Instance, which dont implement a proper outbox yet. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct PersonOutbox { +pub(crate) struct EmptyOutbox { r#type: OrderedCollectionType, id: Url, ordered_items: Vec<()>, total_items: i32, } -impl PersonOutbox { - pub(crate) async fn new(user: Person) -> Result { - Ok(PersonOutbox { +impl EmptyOutbox { + pub(crate) async fn new(outbox_id: Url) -> Result { + Ok(EmptyOutbox { r#type: OrderedCollectionType::OrderedCollection, - id: generate_outbox_url(&user.actor_id)?.into(), + id: outbox_id, ordered_items: vec![], total_items: 0, }) diff --git a/crates/apub/src/protocol/collections/mod.rs b/crates/apub/src/protocol/collections/mod.rs index 183052be6..f34dd1d10 100644 --- a/crates/apub/src/protocol/collections/mod.rs +++ b/crates/apub/src/protocol/collections/mod.rs @@ -1,16 +1,16 @@ +pub(crate) mod empty_outbox; pub(crate) mod group_followers; pub(crate) mod group_moderators; pub(crate) mod group_outbox; -pub(crate) mod person_outbox; #[cfg(test)] mod tests { use crate::protocol::{ collections::{ + empty_outbox::EmptyOutbox, group_followers::GroupFollowers, group_moderators::GroupModerators, group_outbox::GroupOutbox, - person_outbox::PersonOutbox, }, tests::test_parse_lemmy_item, }; @@ -24,6 +24,6 @@ mod tests { assert_eq!(outbox.ordered_items.len() as i32, outbox.total_items); test_parse_lemmy_item::("assets/lemmy/collections/group_moderators.json") .unwrap(); - test_parse_lemmy_item::("assets/lemmy/collections/person_outbox.json").unwrap(); + test_parse_lemmy_item::("assets/lemmy/collections/person_outbox.json").unwrap(); } } diff --git a/crates/apub/src/protocol/mod.rs b/crates/apub/src/protocol/mod.rs index 4b3992fdd..d1532a952 100644 --- a/crates/apub/src/protocol/mod.rs +++ b/crates/apub/src/protocol/mod.rs @@ -17,6 +17,15 @@ pub struct Source { pub(crate) media_type: MediaTypeMarkdown, } +impl Source { + pub(crate) fn new(content: String) -> Self { + Source { + content, + media_type: MediaTypeMarkdown::Markdown, + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ImageObject { diff --git a/crates/apub/src/protocol/objects/instance.rs b/crates/apub/src/protocol/objects/instance.rs new file mode 100644 index 000000000..2a967ac52 --- /dev/null +++ b/crates/apub/src/protocol/objects/instance.rs @@ -0,0 +1,39 @@ +use crate::{ + objects::instance::ApubSite, + protocol::{ImageObject, Source, Unparsed}, +}; +use activitystreams_kinds::actor::ServiceType; +use chrono::{DateTime, FixedOffset}; +use lemmy_apub_lib::{object_id::ObjectId, signatures::PublicKey, values::MediaTypeHtml}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use url::Url; + +#[skip_serializing_none] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Instance { + #[serde(rename = "type")] + pub(crate) kind: ServiceType, + pub(crate) id: ObjectId, + // site name + pub(crate) name: String, + // sidebar + pub(crate) content: Option, + pub(crate) source: Option, + // short instance description + pub(crate) summary: Option, + pub(crate) media_type: Option, + /// instance icon + pub(crate) icon: Option, + /// instance banner + pub(crate) image: Option, + pub(crate) inbox: Url, + /// mandatory field in activitypub, currently empty in lemmy + pub(crate) outbox: Url, + pub(crate) public_key: PublicKey, + pub(crate) published: DateTime, + pub(crate) updated: Option>, + #[serde(flatten)] + pub(crate) unparsed: Unparsed, +} diff --git a/crates/apub/src/protocol/objects/mod.rs b/crates/apub/src/protocol/objects/mod.rs index 2367b6863..20aaca181 100644 --- a/crates/apub/src/protocol/objects/mod.rs +++ b/crates/apub/src/protocol/objects/mod.rs @@ -3,6 +3,7 @@ use url::Url; pub(crate) mod chat_message; pub(crate) mod group; +pub(crate) mod instance; pub(crate) mod note; pub(crate) mod page; pub(crate) mod person; @@ -23,6 +24,7 @@ mod tests { objects::{ chat_message::ChatMessage, group::Group, + instance::Instance, note::Note, page::Page, person::Person, @@ -33,9 +35,10 @@ mod tests { }; #[actix_rt::test] - async fn test_parse_object_lemmy() { - test_parse_lemmy_item::("assets/lemmy/objects/person.json").unwrap(); + async fn test_parse_objects_lemmy() { + test_parse_lemmy_item::("assets/lemmy/objects/instance.json").unwrap(); test_parse_lemmy_item::("assets/lemmy/objects/group.json").unwrap(); + test_parse_lemmy_item::("assets/lemmy/objects/person.json").unwrap(); test_parse_lemmy_item::("assets/lemmy/objects/page.json").unwrap(); test_parse_lemmy_item::("assets/lemmy/objects/note.json").unwrap(); test_parse_lemmy_item::("assets/lemmy/objects/chat_message.json").unwrap(); @@ -43,7 +46,7 @@ mod tests { } #[actix_rt::test] - async fn test_parse_object_pleroma() { + async fn test_parse_objects_pleroma() { file_to_json_object::>("assets/pleroma/objects/person.json").unwrap(); file_to_json_object::>("assets/pleroma/objects/note.json").unwrap(); file_to_json_object::>("assets/pleroma/objects/chat_message.json") @@ -51,19 +54,19 @@ mod tests { } #[actix_rt::test] - async fn test_parse_object_smithereen() { + async fn test_parse_objects_smithereen() { file_to_json_object::>("assets/smithereen/objects/person.json").unwrap(); file_to_json_object::("assets/smithereen/objects/note.json").unwrap(); } #[actix_rt::test] - async fn test_parse_object_mastodon() { + async fn test_parse_objects_mastodon() { file_to_json_object::>("assets/mastodon/objects/person.json").unwrap(); file_to_json_object::>("assets/mastodon/objects/note.json").unwrap(); } #[actix_rt::test] - async fn test_parse_object_lotide() { + async fn test_parse_objects_lotide() { file_to_json_object::>("assets/lotide/objects/group.json").unwrap(); file_to_json_object::>("assets/lotide/objects/person.json").unwrap(); file_to_json_object::>("assets/lotide/objects/note.json").unwrap(); diff --git a/crates/db_schema/src/aggregates/site_aggregates.rs b/crates/db_schema/src/aggregates/site_aggregates.rs index b94899247..58dfe0b01 100644 --- a/crates/db_schema/src/aggregates/site_aggregates.rs +++ b/crates/db_schema/src/aggregates/site_aggregates.rs @@ -55,19 +55,7 @@ mod tests { let site_form = SiteForm { name: "test_site".into(), - sidebar: None, - description: None, - icon: None, - banner: None, - enable_downvotes: None, - open_registration: None, - enable_nsfw: None, - updated: None, - community_creation_admin_only: Some(false), - require_email_verification: None, - require_application: None, - application_question: None, - private_instance: None, + ..Default::default() }; Site::create(&conn, &site_form).unwrap(); @@ -136,7 +124,8 @@ mod tests { let after_delete_creator = SiteAggregates::read(&conn); assert!(after_delete_creator.is_ok()); - Site::delete(&conn, 1).unwrap(); + let site_id = after_delete_creator.unwrap().id; + Site::delete(&conn, site_id).unwrap(); let after_delete_site = SiteAggregates::read(&conn); assert!(after_delete_site.is_err()); } diff --git a/crates/db_schema/src/impls/community.rs b/crates/db_schema/src/impls/community.rs index d2b0d9cde..b2b3a6d81 100644 --- a/crates/db_schema/src/impls/community.rs +++ b/crates/db_schema/src/impls/community.rs @@ -122,9 +122,9 @@ impl Community { .get_result::(conn) } - pub fn distinct_federated_communities(conn: &PgConnection) -> Result, Error> { + pub fn distinct_federated_communities(conn: &PgConnection) -> Result, Error> { use crate::schema::community::dsl::*; - community.select(actor_id).distinct().load::(conn) + community.select(actor_id).distinct().load::(conn) } pub fn upsert(conn: &PgConnection, community_form: &CommunityForm) -> Result { diff --git a/crates/db_schema/src/impls/site.rs b/crates/db_schema/src/impls/site.rs index 8a84bdfbb..60d2c013c 100644 --- a/crates/db_schema/src/impls/site.rs +++ b/crates/db_schema/src/impls/site.rs @@ -1,5 +1,6 @@ -use crate::{source::site::*, traits::Crud}; +use crate::{source::site::*, traits::Crud, DbUrl}; use diesel::{dsl::*, result::Error, *}; +use url::Url; impl Crud for Site { type Form = SiteForm; @@ -27,8 +28,35 @@ impl Crud for Site { } impl Site { - pub fn read_simple(conn: &PgConnection) -> Result { + pub fn read_local_site(conn: &PgConnection) -> Result { use crate::schema::site::dsl::*; - site.first::(conn) + site.order_by(id).first::(conn) + } + + pub fn upsert(conn: &PgConnection, site_form: &SiteForm) -> Result { + use crate::schema::site::dsl::*; + insert_into(site) + .values(site_form) + .on_conflict(actor_id) + .do_update() + .set(site_form) + .get_result::(conn) + } + + pub fn read_from_apub_id(conn: &PgConnection, object_id: Url) -> Result, Error> { + use crate::schema::site::dsl::*; + let object_id: DbUrl = object_id.into(); + Ok( + site + .filter(actor_id.eq(object_id)) + .first::(conn) + .ok() + .map(Into::into), + ) + } + + pub fn read_remote_sites(conn: &PgConnection) -> Result, Error> { + use crate::schema::site::dsl::*; + site.order_by(id).offset(1).get_results::(conn) } } diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index b863a2500..3e68768bc 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -10,6 +10,7 @@ use std::{ fmt, fmt::{Display, Formatter}, io::Write, + ops::Deref, }; use url::Url; @@ -125,3 +126,11 @@ where DbUrl(id.into()) } } + +impl Deref for DbUrl { + type Target = Url; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 681452d9f..b93cd4e72 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -454,6 +454,11 @@ table! { require_application -> Bool, application_question -> Nullable, private_instance -> Bool, + actor_id -> Text, + last_refreshed_at -> Timestamp, + inbox_url -> Text, + private_key -> Nullable, + public_key -> Text, } } diff --git a/crates/db_schema/src/source/site.rs b/crates/db_schema/src/source/site.rs index 01c5bc16e..25bed1c24 100644 --- a/crates/db_schema/src/source/site.rs +++ b/crates/db_schema/src/source/site.rs @@ -20,6 +20,11 @@ pub struct Site { pub require_application: bool, pub application_question: Option, pub private_instance: bool, + pub actor_id: DbUrl, + pub last_refreshed_at: chrono::NaiveDateTime, + pub inbox_url: DbUrl, + pub private_key: Option, + pub public_key: String, } #[derive(Insertable, AsChangeset, Default)] @@ -40,4 +45,9 @@ pub struct SiteForm { pub require_application: Option, pub application_question: Option>, pub private_instance: Option, + pub actor_id: Option, + pub last_refreshed_at: Option, + pub inbox_url: Option, + pub private_key: Option>, + pub public_key: Option, } diff --git a/crates/db_views/src/site_view.rs b/crates/db_views/src/site_view.rs index e77d7c6ce..1e8b8b52a 100644 --- a/crates/db_views/src/site_view.rs +++ b/crates/db_views/src/site_view.rs @@ -14,11 +14,12 @@ pub struct SiteView { impl SiteView { pub fn read(conn: &PgConnection) -> Result { - let (site, counts) = site::table + let (mut site, counts) = site::table .inner_join(site_aggregates::table) .select((site::all_columns, site_aggregates::all_columns)) .first::<(Site, SiteAggregates)>(conn)?; + site.private_key = None; Ok(SiteView { site, counts }) } } diff --git a/migrations/2022-01-28-104106_instance-actor/down.sql b/migrations/2022-01-28-104106_instance-actor/down.sql new file mode 100644 index 000000000..a258c27a4 --- /dev/null +++ b/migrations/2022-01-28-104106_instance-actor/down.sql @@ -0,0 +1,6 @@ +alter table site + drop column actor_id, + drop column last_refreshed_at, + drop column inbox_url, + drop column private_key, + drop column public_key; diff --git a/migrations/2022-01-28-104106_instance-actor/up.sql b/migrations/2022-01-28-104106_instance-actor/up.sql new file mode 100644 index 000000000..914ab757e --- /dev/null +++ b/migrations/2022-01-28-104106_instance-actor/up.sql @@ -0,0 +1,6 @@ +alter table site + add column actor_id varchar(255) not null unique default generate_unique_changeme(), + add column last_refreshed_at Timestamp not null default now(), + add column inbox_url varchar(255) not null default generate_unique_changeme(), + add column private_key text, + add column public_key text not null default generate_unique_changeme(); diff --git a/src/code_migrations.rs b/src/code_migrations.rs index 4737066bb..161dd3b60 100644 --- a/src/code_migrations.rs +++ b/src/code_migrations.rs @@ -8,6 +8,7 @@ use lemmy_apub::{ generate_inbox_url, generate_local_apub_endpoint, generate_shared_inbox_url, + generate_site_inbox_url, EndpointType, }; use lemmy_db_schema::{ @@ -18,11 +19,13 @@ use lemmy_db_schema::{ person::{Person, PersonForm}, post::Post, private_message::PrivateMessage, + site::{Site, SiteForm}, }, traits::Crud, }; use lemmy_utils::{apub::generate_actor_keypair, LemmyError}; use tracing::info; +use url::Url; pub fn run_advanced_migrations( conn: &PgConnection, @@ -35,6 +38,7 @@ pub fn run_advanced_migrations( private_message_updates_2020_05_05(conn, protocol_and_hostname)?; post_thumbnail_url_updates_2020_07_27(conn, protocol_and_hostname)?; apub_columns_2021_02_02(conn)?; + instance_actor_2022_01_28(conn, protocol_and_hostname)?; Ok(()) } @@ -284,3 +288,29 @@ fn apub_columns_2021_02_02(conn: &PgConnection) -> Result<(), LemmyError> { Ok(()) } + +/// Site object turns into an actor, so that things like instance description can be federated. This +/// means we need to add actor columns to the site table, and initialize them with correct values. +/// Before this point, there is only a single value in the site table which refers to the local +/// Lemmy instance, so thats all we need to update. +fn instance_actor_2022_01_28( + conn: &PgConnection, + protocol_and_hostname: &str, +) -> Result<(), LemmyError> { + info!("Running instance_actor_2021_09_29"); + if let Ok(site) = Site::read_local_site(conn) { + let key_pair = generate_actor_keypair()?; + let actor_id = Url::parse(protocol_and_hostname)?; + let site_form = SiteForm { + name: site.name, + actor_id: Some(actor_id.clone().into()), + last_refreshed_at: Some(naive_now()), + inbox_url: Some(generate_site_inbox_url(&actor_id.into())?), + private_key: Some(Some(key_pair.private_key)), + public_key: Some(key_pair.public_key), + ..Default::default() + }; + Site::update(conn, site.id, &site_form)?; + } + Ok(()) +}