From fbd219a370253c03badb606b51e6ae7cb7fe3208 Mon Sep 17 00:00:00 2001 From: Richard Schwab Date: Fri, 27 Sep 2024 04:11:06 +0200 Subject: [PATCH] Allow admins to resolve removed or deleted objects via API --- api_tests/src/comment.spec.ts | 17 ++- crates/apub/src/api/mod.rs | 2 + crates/apub/src/api/resolve_object.rs | 141 +++++++++++++++++--- crates/apub/src/api/test.rs | 44 ++++++ crates/apub/src/api/user_settings_backup.rs | 88 ++++++------ crates/apub/src/fetcher/markdown_links.rs | 4 +- 6 files changed, 224 insertions(+), 72 deletions(-) create mode 100644 crates/apub/src/api/test.rs diff --git a/api_tests/src/comment.spec.ts b/api_tests/src/comment.spec.ts index 5f2059e4f..153405820 100644 --- a/api_tests/src/comment.spec.ts +++ b/api_tests/src/comment.spec.ts @@ -158,16 +158,16 @@ test("Delete a comment", async () => { expect(deleteCommentRes.comment_view.comment.deleted).toBe(true); expect(deleteCommentRes.comment_view.comment.content).toBe(""); - // Make sure that comment is undefined on beta + // Make sure that comment is deleted on beta await waitUntil( - () => resolveComment(beta, commentRes.comment_view.comment).catch(e => e), - e => e.message == "not_found", + () => resolveComment(beta, commentRes.comment_view.comment), + c => c.comment?.comment.deleted === true, ); - // Make sure that comment is undefined on gamma after delete + // Make sure that comment is deleted on gamma after delete await waitUntil( - () => resolveComment(gamma, commentRes.comment_view.comment).catch(e => e), - e => e.message === "not_found", + () => resolveComment(gamma, commentRes.comment_view.comment), + c => c.comment?.comment.deleted === true, ); // Test undeleting the comment @@ -181,11 +181,10 @@ test("Delete a comment", async () => { // Make sure that comment is undeleted on beta let betaComment2 = ( await waitUntil( - () => resolveComment(beta, commentRes.comment_view.comment).catch(e => e), - e => e.message !== "not_found", + () => resolveComment(beta, commentRes.comment_view.comment), + c => c.comment?.comment.deleted === false, ) ).comment; - expect(betaComment2?.comment.deleted).toBe(false); assertCommentFederation(betaComment2, undeleteCommentRes.comment_view); }); diff --git a/crates/apub/src/api/mod.rs b/crates/apub/src/api/mod.rs index 580be3228..75ec51043 100644 --- a/crates/apub/src/api/mod.rs +++ b/crates/apub/src/api/mod.rs @@ -12,6 +12,8 @@ pub mod read_community; pub mod read_person; pub mod resolve_object; pub mod search; +#[cfg(test)] +pub(crate) mod test; pub mod user_settings_backup; /// Returns default listing type, depending if the query is for frontpage or community. diff --git a/crates/apub/src/api/resolve_object.rs b/crates/apub/src/api/resolve_object.rs index 81329a97b..d0b8a9769 100644 --- a/crates/apub/src/api/resolve_object.rs +++ b/crates/apub/src/api/resolve_object.rs @@ -5,7 +5,6 @@ use crate::fetcher::{ }; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; -use diesel::NotFound; use lemmy_api_common::{ context::LemmyContext, site::{ResolveObject, ResolveObjectResponse}, @@ -47,36 +46,146 @@ async fn convert_response( local_user_view: Option, pool: &mut DbPool<'_>, ) -> LemmyResult> { - let removed_or_deleted; let mut res = ResolveObjectResponse::default(); let local_user = local_user_view.map(|l| l.local_user); + let is_admin = local_user.clone().map(|l| l.admin).unwrap_or_default(); match object { SearchableObjects::PostOrComment(pc) => match *pc { PostOrComment::Post(p) => { - removed_or_deleted = p.deleted || p.removed; - res.post = Some(PostView::read(pool, p.id, local_user.as_ref(), false).await?) + res.post = Some(PostView::read(pool, p.id, local_user.as_ref(), is_admin).await?) } PostOrComment::Comment(c) => { - removed_or_deleted = c.deleted || c.removed; res.comment = Some(CommentView::read(pool, c.id, local_user.as_ref()).await?) } }, SearchableObjects::PersonOrCommunity(pc) => match *pc { - UserOrCommunity::User(u) => { - removed_or_deleted = u.deleted; - res.person = Some(PersonView::read(pool, u.id).await?) - } + UserOrCommunity::User(u) => res.person = Some(PersonView::read(pool, u.id).await?), UserOrCommunity::Community(c) => { - removed_or_deleted = c.deleted || c.removed; - res.community = Some(CommunityView::read(pool, c.id, local_user.as_ref(), false).await?) + res.community = Some(CommunityView::read(pool, c.id, local_user.as_ref(), is_admin).await?) } }, }; - // if the object was deleted from database, dont return it - if removed_or_deleted { - Err(NotFound {}.into()) - } else { - Ok(Json(res)) + + Ok(Json(res)) +} + +#[cfg(test)] +mod tests { + use crate::api::{resolve_object::resolve_object, test::TestUser}; + use actix_web::web::Query; + use lemmy_api_common::{context::LemmyContext, site::ResolveObject}; + use lemmy_db_schema::{ + source::{ + community::{Community, CommunityInsertForm}, + instance::Instance, + local_site::{LocalSite, LocalSiteInsertForm}, + post::{Post, PostInsertForm, PostUpdateForm}, + site::{Site, SiteInsertForm}, + }, + traits::Crud, + }; + use lemmy_utils::{error::LemmyResult, LemmyErrorType}; + use serial_test::serial; + + #[tokio::test] + #[serial] + #[expect(clippy::unwrap_used)] + async fn test_object_visibility() -> LemmyResult<()> { + let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); + + let creator = TestUser::default().create(pool).await?; + let regular_user = TestUser::default().create(pool).await?; + let admin_user = TestUser { + admin: true, + ..Default::default() + } + .create(pool) + .await?; + + let instance_id = creator.person.instance_id; + let site_form = SiteInsertForm::new("test site".to_string(), instance_id); + let site = Site::create(pool, &site_form).await?; + + let local_site_form = LocalSiteInsertForm { + site_setup: Some(true), + private_instance: Some(false), + ..LocalSiteInsertForm::new(site.id) + }; + LocalSite::create(pool, &local_site_form).await?; + + let community = Community::create( + pool, + &CommunityInsertForm::new( + instance_id, + "test".to_string(), + "test".to_string(), + "pubkey".to_string(), + ), + ) + .await?; + + let post_insert_form = PostInsertForm::new("Test".to_string(), creator.person.id, community.id); + let post = Post::create(pool, &post_insert_form).await?; + + let query = format!("q={}", post.ap_id).to_string(); + let query: Query = Query::from_query(&query)?; + + // Objects should be resolvable without authentication + let res = resolve_object(query.clone(), context.reset_request_count(), None).await?; + assert_eq!(res.post.as_ref().unwrap().post.ap_id, post.ap_id); + // Objects should be resolvable by regular users + let res = resolve_object( + query.clone(), + context.reset_request_count(), + Some(regular_user.clone()), + ) + .await?; + assert_eq!(res.post.as_ref().unwrap().post.ap_id, post.ap_id); + // Objects should be resolvable by admins + let res = resolve_object( + query.clone(), + context.reset_request_count(), + Some(admin_user.clone()), + ) + .await?; + assert_eq!(res.post.as_ref().unwrap().post.ap_id, post.ap_id); + + Post::update( + pool, + post.id, + &PostUpdateForm { + deleted: Some(true), + ..Default::default() + }, + ) + .await?; + + // Deleted objects should not be resolvable without authentication + let res = resolve_object(query.clone(), context.reset_request_count(), None).await; + assert!(res.is_err_and(|e| e.error_type == LemmyErrorType::NotFound)); + // Deleted objects should not be resolvable by regular users + let res = resolve_object( + query.clone(), + context.reset_request_count(), + Some(regular_user.clone()), + ) + .await; + assert!(res.is_err_and(|e| e.error_type == LemmyErrorType::NotFound)); + // Deleted objects should be resolvable by admins + let res = resolve_object( + query.clone(), + context.reset_request_count(), + Some(admin_user.clone()), + ) + .await?; + assert_eq!(res.post.as_ref().unwrap().post.ap_id, post.ap_id); + + LocalSite::delete(pool).await?; + Site::delete(pool, site.id).await?; + Instance::delete(pool, instance_id).await?; + + Ok(()) } } diff --git a/crates/apub/src/api/test.rs b/crates/apub/src/api/test.rs new file mode 100644 index 000000000..55ce6bab3 --- /dev/null +++ b/crates/apub/src/api/test.rs @@ -0,0 +1,44 @@ +use lemmy_db_schema::{ + source::{ + instance::Instance, + local_user::{LocalUser, LocalUserInsertForm}, + person::{Person, PersonInsertForm}, + }, + traits::Crud, + utils::DbPool, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::LemmyResult; + +#[derive(Default)] +pub struct TestUser { + pub name: Option<&'static str>, + pub bio: Option<&'static str>, + pub admin: bool, +} + +impl TestUser { + pub async fn create(self, pool: &mut DbPool<'_>) -> LemmyResult { + let instance_id = Instance::read_or_create(pool, "example.com".to_string()) + .await? + .id; + let name = self + .name + .map_or_else(|| uuid::Uuid::new_v4().to_string(), ToString::to_string); + + let person_form = PersonInsertForm { + display_name: Some(name.clone()), + bio: self.bio.map(ToString::to_string), + ..PersonInsertForm::test_form(instance_id, &name) + }; + let person = Person::create(pool, &person_form).await?; + + let user_form = match self.admin { + true => LocalUserInsertForm::test_form_admin(person.id), + false => LocalUserInsertForm::test_form(person.id), + }; + let local_user = LocalUser::create(pool, &user_form, vec![]).await?; + + Ok(LocalUserView::read(pool, local_user.id).await?) + } +} diff --git a/crates/apub/src/api/user_settings_backup.rs b/crates/apub/src/api/user_settings_backup.rs index 36a6907a0..50017df9e 100644 --- a/crates/apub/src/api/user_settings_backup.rs +++ b/crates/apub/src/api/user_settings_backup.rs @@ -314,17 +314,16 @@ where #[cfg(test)] #[expect(clippy::indexing_slicing)] pub(crate) mod tests { - - use crate::api::user_settings_backup::{export_settings, import_settings}; - use activitypub_federation::config::Data; + use crate::api::{ + test::TestUser, + user_settings_backup::{export_settings, import_settings}, + }; use actix_web::web::Json; use lemmy_api_common::context::LemmyContext; use lemmy_db_schema::{ source::{ community::{Community, CommunityFollower, CommunityFollowerForm, CommunityInsertForm}, - instance::Instance, - local_user::{LocalUser, LocalUserInsertForm}, - person::{Person, PersonInsertForm}, + local_user::LocalUser, }, traits::{Crud, Followable}, }; @@ -336,32 +335,19 @@ pub(crate) mod tests { use std::time::Duration; use tokio::time::sleep; - pub(crate) async fn create_user( - name: String, - bio: Option, - context: &Data, - ) -> LemmyResult { - let instance = Instance::read_or_create(&mut context.pool(), "example.com".to_string()).await?; - let person_form = PersonInsertForm { - display_name: Some(name.clone()), - bio, - ..PersonInsertForm::test_form(instance.id, &name) - }; - let person = Person::create(&mut context.pool(), &person_form).await?; - - let user_form = LocalUserInsertForm::test_form(person.id); - let local_user = LocalUser::create(&mut context.pool(), &user_form, vec![]).await?; - - Ok(LocalUserView::read(&mut context.pool(), local_user.id).await?) - } - #[tokio::test] #[serial] async fn test_settings_export_import() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); - let export_user = - create_user("hanna".to_string(), Some("my bio".to_string()), &context).await?; + let export_user = TestUser { + name: "hanna".into(), + bio: "my bio".into(), + ..Default::default() + } + .create(pool) + .await?; let community_form = CommunityInsertForm::new( export_user.person.instance_id, @@ -369,25 +355,29 @@ pub(crate) mod tests { "testcom".to_string(), "pubkey".to_string(), ); - let community = Community::create(&mut context.pool(), &community_form).await?; + let community = Community::create(pool, &community_form).await?; let follower_form = CommunityFollowerForm { community_id: community.id, person_id: export_user.person.id, pending: false, }; - CommunityFollower::follow(&mut context.pool(), &follower_form).await?; + CommunityFollower::follow(pool, &follower_form).await?; let backup = export_settings(export_user.clone(), context.reset_request_count()).await?; - let import_user = create_user("charles".to_string(), None, &context).await?; + let import_user = TestUser { + name: "charles".into(), + ..Default::default() + } + .create(pool) + .await?; import_settings(backup, import_user.clone(), context.reset_request_count()).await?; // wait for background task to finish sleep(Duration::from_millis(1000)).await; - let import_user_updated = - LocalUserView::read(&mut context.pool(), import_user.local_user.id).await?; + let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).await?; assert_eq!( export_user.person.display_name, @@ -395,13 +385,12 @@ pub(crate) mod tests { ); assert_eq!(export_user.person.bio, import_user_updated.person.bio); - let follows = - CommunityFollowerView::for_person(&mut context.pool(), import_user.person.id).await?; + let follows = CommunityFollowerView::for_person(pool, import_user.person.id).await?; assert_eq!(follows.len(), 1); assert_eq!(follows[0].community.actor_id, community.actor_id); - LocalUser::delete(&mut context.pool(), export_user.local_user.id).await?; - LocalUser::delete(&mut context.pool(), import_user.local_user.id).await?; + LocalUser::delete(pool, export_user.local_user.id).await?; + LocalUser::delete(pool, import_user.local_user.id).await?; Ok(()) } @@ -409,9 +398,14 @@ pub(crate) mod tests { #[serial] async fn disallow_large_backup() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); - let export_user = - create_user("hanna".to_string(), Some("my bio".to_string()), &context).await?; + let export_user = TestUser { + bio: "my bio".into(), + ..Default::default() + } + .create(pool) + .await?; let mut backup = export_settings(export_user.clone(), context.reset_request_count()).await?; @@ -426,7 +420,7 @@ pub(crate) mod tests { backup.saved_comments.push("http://example4.com".parse()?); } - let import_user = create_user("charles".to_string(), None, &context).await?; + let import_user = TestUser::default().create(pool).await?; let imported = import_settings(backup, import_user.clone(), context.reset_request_count()).await; @@ -436,8 +430,8 @@ pub(crate) mod tests { Some(LemmyErrorType::TooManyItems) ); - LocalUser::delete(&mut context.pool(), export_user.local_user.id).await?; - LocalUser::delete(&mut context.pool(), import_user.local_user.id).await?; + LocalUser::delete(pool, export_user.local_user.id).await?; + LocalUser::delete(pool, import_user.local_user.id).await?; Ok(()) } @@ -445,9 +439,14 @@ pub(crate) mod tests { #[serial] async fn import_partial_backup() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); - let import_user = - create_user("hanna".to_string(), Some("my bio".to_string()), &context).await?; + let import_user = TestUser { + bio: "my bio".into(), + ..Default::default() + } + .create(pool) + .await?; let backup = serde_json::from_str("{\"bot_account\": true, \"settings\": {\"theme\": \"my_theme\"}}")?; @@ -458,8 +457,7 @@ pub(crate) mod tests { ) .await?; - let import_user_updated = - LocalUserView::read(&mut context.pool(), import_user.local_user.id).await?; + let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).await?; // mark as bot account assert!(import_user_updated.person.bot_account); // dont remove existing bio diff --git a/crates/apub/src/fetcher/markdown_links.rs b/crates/apub/src/fetcher/markdown_links.rs index 168958589..009b333c0 100644 --- a/crates/apub/src/fetcher/markdown_links.rs +++ b/crates/apub/src/fetcher/markdown_links.rs @@ -104,7 +104,7 @@ async fn format_actor_url( #[cfg(test)] mod tests { use super::*; - use crate::api::user_settings_backup::tests::create_user; + use crate::api::test::TestUser; use lemmy_db_schema::{ source::{ community::{Community, CommunityInsertForm}, @@ -130,7 +130,7 @@ mod tests { ), ) .await?; - let user = create_user("john".to_string(), None, &context).await?; + let user = TestUser::default().create(&mut context.pool()).await?; // insert a remote post which is already fetched let post_form = PostInsertForm {