Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow admins to resolve removed or deleted objects via API #5061

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions api_tests/src/comment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
});

Expand Down
140 changes: 124 additions & 16 deletions crates/apub/src/api/resolve_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -47,36 +46,145 @@ async fn convert_response(
local_user_view: Option<LocalUserView>,
pool: &mut DbPool<'_>,
) -> LemmyResult<Json<ResolveObjectResponse>> {
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;
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_db_views::structs::LocalUserView;
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 name = "test_local_user_name";
let bio = "test_local_user_bio";

let creator = LocalUserView::create_test_user(pool, name, bio, false).await?;
let regular_user = LocalUserView::create_test_user(pool, name, bio, false).await?;
let admin_user = LocalUserView::create_test_user(pool, name, bio, true).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<ResolveObject> = 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?;
Nutomic marked this conversation as resolved.
Show resolved Hide resolved
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(())
}
}
63 changes: 19 additions & 44 deletions crates/apub/src/api/user_settings_backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,17 +314,13 @@ 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 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},
};
Expand All @@ -336,82 +332,62 @@ pub(crate) mod tests {
use std::time::Duration;
use tokio::time::sleep;

pub(crate) async fn create_user(
name: String,
bio: Option<String>,
context: &Data<LemmyContext>,
) -> LemmyResult<LocalUserView> {
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 = LocalUserView::create_test_user(pool, "hanna", "my bio", false).await?;

let community_form = CommunityInsertForm::new(
export_user.person.instance_id,
"testcom".to_string(),
"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 =
LocalUserView::create_test_user(pool, "charles", "charles bio", false).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,
import_user_updated.person.display_name
);
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(())
}

#[tokio::test]
#[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 = LocalUserView::create_test_user(pool, "harry", "harry bio", false).await?;

let mut backup = export_settings(export_user.clone(), context.reset_request_count()).await?;

Expand All @@ -426,7 +402,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 = LocalUserView::create_test_user(pool, "sally", "sally bio", false).await?;

let imported =
import_settings(backup, import_user.clone(), context.reset_request_count()).await;
Expand All @@ -436,18 +412,18 @@ 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(())
}

#[tokio::test]
#[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 = LocalUserView::create_test_user(pool, "larry", "larry bio", false).await?;

let backup =
serde_json::from_str("{\"bot_account\": true, \"settings\": {\"theme\": \"my_theme\"}}")?;
Expand All @@ -458,8 +434,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
Expand Down
5 changes: 3 additions & 2 deletions crates/apub/src/fetcher/markdown_links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,14 @@ async fn format_actor_url(
#[cfg(test)]
mod tests {
use super::*;
use crate::api::user_settings_backup::tests::create_user;
use lemmy_db_schema::{
source::{
community::{Community, CommunityInsertForm},
post::{Post, PostInsertForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use pretty_assertions::assert_eq;
use serial_test::serial;

Expand All @@ -130,7 +130,8 @@ mod tests {
),
)
.await?;
let user = create_user("john".to_string(), None, &context).await?;
let user =
LocalUserView::create_test_user(&mut context.pool(), "garda", "garda bio", false).await?;

// insert a remote post which is already fetched
let post_form = PostInsertForm {
Expand Down
Loading