From: Dessalines Date: Mon, 13 Jun 2022 19:15:04 +0000 (-0400) Subject: Adding admin purging of DB items and pictures. #904 #1331 (#1809) X-Git-Url: http://these/git/%7B%60%24%7BwebArchiveUrl%7D/%22%7B%7D/readmes/%7B%7D/%22%7Burl%7D/%7BpictrsAvatarThumbnail%28?a=commitdiff_plain;h=4e12e25c59beef296c750fec640b0b80c4c11de9;p=lemmy.git Adding admin purging of DB items and pictures. #904 #1331 (#1809) * First pass at adding admin purge. #904 #1331 * Breaking out purge into 4 tables for the 4 purgeable types. * Using CommunitySafe instead in view * Fix db_schema features flags. * Attempting to pass API key. * Adding pictrs image purging - Added pictrs_config block, for API_KEY - Clear out image columns after purging * Remove the remove_images field from a few of the purge API calls. * Fix some suggestions by @nutomic. * Add separate pictrs reqwest client. * Update defaults.hjson Co-authored-by: Nutomic --- diff --git a/config/defaults.hjson b/config/defaults.hjson index 1ec60243..acd956a8 100644 --- a/config/defaults.hjson +++ b/config/defaults.hjson @@ -70,6 +70,13 @@ # activities synchronously for easier testing. Do not use in production. debug: false } + # Pictrs image server configuration. + pictrs_config: { + # Address where pictrs is available (for image hosting) + url: "string" + # Set a custom pictrs API key. ( Required for deleting images ) + api_key: "string" + } captcha: { # Whether captcha is required for signup enabled: false @@ -108,8 +115,7 @@ port: 8536 # Whether the site is available over TLS. Needs to be true for federation to work. tls_enabled: true - # Address where pictrs is available (for image hosting) - pictrs_url: "http://localhost:8080" + # A regex list of slurs to block / hide slur_filter: "(\bThis\b)|(\bis\b)|(\bsample\b)" # Maximum length of local community and user names actor_name_max_length: 20 diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 6e2bb4f0..5083b286 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -104,6 +104,16 @@ pub async fn match_websocket_operation( UserOperation::SaveSiteConfig => { do_websocket_operation::(context, id, op, data).await } + UserOperation::PurgePerson => { + do_websocket_operation::(context, id, op, data).await + } + UserOperation::PurgeCommunity => { + do_websocket_operation::(context, id, op, data).await + } + UserOperation::PurgePost => do_websocket_operation::(context, id, op, data).await, + UserOperation::PurgeComment => { + do_websocket_operation::(context, id, op, data).await + } UserOperation::Search => do_websocket_operation::(context, id, op, data).await, UserOperation::ResolveObject => { do_websocket_operation::(context, id, op, data).await diff --git a/crates/api/src/local_user/ban_person.rs b/crates/api/src/local_user/ban_person.rs index 6de826dc..e9900985 100644 --- a/crates/api/src/local_user/ban_person.rs +++ b/crates/api/src/local_user/ban_person.rs @@ -49,7 +49,13 @@ impl Perform for BanPerson { // Remove their data if that's desired let remove_data = data.remove_data.unwrap_or(false); if remove_data { - remove_user_data(person.id, context.pool()).await?; + remove_user_data( + person.id, + context.pool(), + &context.settings(), + context.client(), + ) + .await?; } // Mod tables diff --git a/crates/api/src/site/mod.rs b/crates/api/src/site/mod.rs index b8b9dd78..1c860cb4 100644 --- a/crates/api/src/site/mod.rs +++ b/crates/api/src/site/mod.rs @@ -1,6 +1,7 @@ mod config; mod leave_admin; mod mod_log; +mod purge; mod registration_applications; mod resolve_object; mod search; diff --git a/crates/api/src/site/mod_log.rs b/crates/api/src/site/mod_log.rs index acb5e827..a96595da 100644 --- a/crates/api/src/site/mod_log.rs +++ b/crates/api/src/site/mod_log.rs @@ -5,6 +5,10 @@ use lemmy_api_common::{ utils::{blocking, check_private_instance, get_local_user_view_from_jwt_opt}, }; use lemmy_db_views_moderator::structs::{ + AdminPurgeCommentView, + AdminPurgeCommunityView, + AdminPurgePersonView, + AdminPurgePostView, ModAddCommunityView, ModAddView, ModBanFromCommunityView, @@ -83,17 +87,29 @@ impl Perform for GetModlog { .await??; // These arrays are only for the full modlog, when a community isn't given - let (removed_communities, banned, added) = if data.community_id.is_none() { + let ( + removed_communities, + banned, + added, + admin_purged_persons, + admin_purged_communities, + admin_purged_posts, + admin_purged_comments, + ) = if data.community_id.is_none() { blocking(context.pool(), move |conn| { Ok(( ModRemoveCommunityView::list(conn, mod_person_id, page, limit)?, ModBanView::list(conn, mod_person_id, page, limit)?, ModAddView::list(conn, mod_person_id, page, limit)?, + AdminPurgePersonView::list(conn, mod_person_id, page, limit)?, + AdminPurgeCommunityView::list(conn, mod_person_id, page, limit)?, + AdminPurgePostView::list(conn, mod_person_id, page, limit)?, + AdminPurgeCommentView::list(conn, mod_person_id, page, limit)?, )) as Result<_, LemmyError> }) .await?? } else { - (Vec::new(), Vec::new(), Vec::new()) + Default::default() }; // Return the jwt @@ -108,6 +124,10 @@ impl Perform for GetModlog { added_to_community, added, transferred_to_community, + admin_purged_persons, + admin_purged_communities, + admin_purged_posts, + admin_purged_comments, hidden_communities, }) } diff --git a/crates/api/src/site/purge/comment.rs b/crates/api/src/site/purge/comment.rs new file mode 100644 index 00000000..e8509318 --- /dev/null +++ b/crates/api/src/site/purge/comment.rs @@ -0,0 +1,63 @@ +use crate::Perform; +use actix_web::web::Data; +use lemmy_api_common::{ + site::{PurgeComment, PurgeItemResponse}, + utils::{blocking, get_local_user_view_from_jwt, is_admin}, +}; +use lemmy_db_schema::{ + source::{ + comment::Comment, + moderator::{AdminPurgeComment, AdminPurgeCommentForm}, + }, + traits::Crud, +}; +use lemmy_utils::{error::LemmyError, ConnectionId}; +use lemmy_websocket::LemmyContext; + +#[async_trait::async_trait(?Send)] +impl Perform for PurgeComment { + type Response = PurgeItemResponse; + + #[tracing::instrument(skip(context, _websocket_id))] + async fn perform( + &self, + context: &Data, + _websocket_id: Option, + ) -> Result { + let data: &Self = self; + let local_user_view = + get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?; + + // Only let admins purge an item + is_admin(&local_user_view)?; + + let comment_id = data.comment_id; + + // Read the comment to get the post_id + let comment = blocking(context.pool(), move |conn| Comment::read(conn, comment_id)).await??; + + let post_id = comment.post_id; + + // TODO read comments for pictrs images and purge them + + blocking(context.pool(), move |conn| { + Comment::delete(conn, comment_id) + }) + .await??; + + // Mod tables + let reason = data.reason.to_owned(); + let form = AdminPurgeCommentForm { + admin_person_id: local_user_view.person.id, + reason, + post_id, + }; + + blocking(context.pool(), move |conn| { + AdminPurgeComment::create(conn, &form) + }) + .await??; + + Ok(PurgeItemResponse { success: true }) + } +} diff --git a/crates/api/src/site/purge/community.rs b/crates/api/src/site/purge/community.rs new file mode 100644 index 00000000..9acb2a82 --- /dev/null +++ b/crates/api/src/site/purge/community.rs @@ -0,0 +1,82 @@ +use crate::Perform; +use actix_web::web::Data; +use lemmy_api_common::{ + request::purge_image_from_pictrs, + site::{PurgeCommunity, PurgeItemResponse}, + utils::{blocking, get_local_user_view_from_jwt, is_admin, purge_image_posts_for_community}, +}; +use lemmy_db_schema::{ + source::{ + community::Community, + moderator::{AdminPurgeCommunity, AdminPurgeCommunityForm}, + }, + traits::Crud, +}; +use lemmy_utils::{error::LemmyError, ConnectionId}; +use lemmy_websocket::LemmyContext; + +#[async_trait::async_trait(?Send)] +impl Perform for PurgeCommunity { + type Response = PurgeItemResponse; + + #[tracing::instrument(skip(context, _websocket_id))] + async fn perform( + &self, + context: &Data, + _websocket_id: Option, + ) -> Result { + let data: &Self = self; + let local_user_view = + get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?; + + // Only let admins purge an item + is_admin(&local_user_view)?; + + let community_id = data.community_id; + + // Read the community to get its images + let community = blocking(context.pool(), move |conn| { + Community::read(conn, community_id) + }) + .await??; + + if let Some(banner) = community.banner { + purge_image_from_pictrs(context.client(), &context.settings(), &banner) + .await + .ok(); + } + + if let Some(icon) = community.icon { + purge_image_from_pictrs(context.client(), &context.settings(), &icon) + .await + .ok(); + } + + purge_image_posts_for_community( + community_id, + context.pool(), + &context.settings(), + context.client(), + ) + .await?; + + blocking(context.pool(), move |conn| { + Community::delete(conn, community_id) + }) + .await??; + + // Mod tables + let reason = data.reason.to_owned(); + let form = AdminPurgeCommunityForm { + admin_person_id: local_user_view.person.id, + reason, + }; + + blocking(context.pool(), move |conn| { + AdminPurgeCommunity::create(conn, &form) + }) + .await??; + + Ok(PurgeItemResponse { success: true }) + } +} diff --git a/crates/api/src/site/purge/mod.rs b/crates/api/src/site/purge/mod.rs new file mode 100644 index 00000000..aa00ba6e --- /dev/null +++ b/crates/api/src/site/purge/mod.rs @@ -0,0 +1,4 @@ +mod comment; +mod community; +mod person; +mod post; diff --git a/crates/api/src/site/purge/person.rs b/crates/api/src/site/purge/person.rs new file mode 100644 index 00000000..fe875858 --- /dev/null +++ b/crates/api/src/site/purge/person.rs @@ -0,0 +1,75 @@ +use crate::Perform; +use actix_web::web::Data; +use lemmy_api_common::{ + request::purge_image_from_pictrs, + site::{PurgeItemResponse, PurgePerson}, + utils::{blocking, get_local_user_view_from_jwt, is_admin, purge_image_posts_for_person}, +}; +use lemmy_db_schema::{ + source::{ + moderator::{AdminPurgePerson, AdminPurgePersonForm}, + person::Person, + }, + traits::Crud, +}; +use lemmy_utils::{error::LemmyError, ConnectionId}; +use lemmy_websocket::LemmyContext; + +#[async_trait::async_trait(?Send)] +impl Perform for PurgePerson { + type Response = PurgeItemResponse; + + #[tracing::instrument(skip(context, _websocket_id))] + async fn perform( + &self, + context: &Data, + _websocket_id: Option, + ) -> Result { + let data: &Self = self; + let local_user_view = + get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?; + + // Only let admins purge an item + is_admin(&local_user_view)?; + + // Read the person to get their images + let person_id = data.person_id; + let person = blocking(context.pool(), move |conn| Person::read(conn, person_id)).await??; + + if let Some(banner) = person.banner { + purge_image_from_pictrs(context.client(), &context.settings(), &banner) + .await + .ok(); + } + + if let Some(avatar) = person.avatar { + purge_image_from_pictrs(context.client(), &context.settings(), &avatar) + .await + .ok(); + } + + purge_image_posts_for_person( + person_id, + context.pool(), + &context.settings(), + context.client(), + ) + .await?; + + blocking(context.pool(), move |conn| Person::delete(conn, person_id)).await??; + + // Mod tables + let reason = data.reason.to_owned(); + let form = AdminPurgePersonForm { + admin_person_id: local_user_view.person.id, + reason, + }; + + blocking(context.pool(), move |conn| { + AdminPurgePerson::create(conn, &form) + }) + .await??; + + Ok(PurgeItemResponse { success: true }) + } +} diff --git a/crates/api/src/site/purge/post.rs b/crates/api/src/site/purge/post.rs new file mode 100644 index 00000000..99080083 --- /dev/null +++ b/crates/api/src/site/purge/post.rs @@ -0,0 +1,72 @@ +use crate::Perform; +use actix_web::web::Data; +use lemmy_api_common::{ + request::purge_image_from_pictrs, + site::{PurgeItemResponse, PurgePost}, + utils::{blocking, get_local_user_view_from_jwt, is_admin}, +}; +use lemmy_db_schema::{ + source::{ + moderator::{AdminPurgePost, AdminPurgePostForm}, + post::Post, + }, + traits::Crud, +}; +use lemmy_utils::{error::LemmyError, ConnectionId}; +use lemmy_websocket::LemmyContext; + +#[async_trait::async_trait(?Send)] +impl Perform for PurgePost { + type Response = PurgeItemResponse; + + #[tracing::instrument(skip(context, _websocket_id))] + async fn perform( + &self, + context: &Data, + _websocket_id: Option, + ) -> Result { + let data: &Self = self; + let local_user_view = + get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?; + + // Only let admins purge an item + is_admin(&local_user_view)?; + + let post_id = data.post_id; + + // Read the post to get the community_id + let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??; + + // Purge image + if let Some(url) = post.url { + purge_image_from_pictrs(context.client(), &context.settings(), &url) + .await + .ok(); + } + // Purge thumbnail + if let Some(thumbnail_url) = post.thumbnail_url { + purge_image_from_pictrs(context.client(), &context.settings(), &thumbnail_url) + .await + .ok(); + } + + let community_id = post.community_id; + + blocking(context.pool(), move |conn| Post::delete(conn, post_id)).await??; + + // Mod tables + let reason = data.reason.to_owned(); + let form = AdminPurgePostForm { + admin_person_id: local_user_view.person.id, + reason, + community_id, + }; + + blocking(context.pool(), move |conn| { + AdminPurgePost::create(conn, &form) + }) + .await??; + + Ok(PurgeItemResponse { success: true }) + } +} diff --git a/crates/api_common/src/request.rs b/crates/api_common/src/request.rs index 85789960..37bb39a0 100644 --- a/crates/api_common/src/request.rs +++ b/crates/api_common/src/request.rs @@ -1,7 +1,12 @@ use crate::post::SiteMetadata; use encoding::{all::encodings, DecoderTrap}; use lemmy_db_schema::newtypes::DbUrl; -use lemmy_utils::{error::LemmyError, settings::structs::Settings, version::VERSION}; +use lemmy_utils::{ + error::LemmyError, + settings::structs::Settings, + version::VERSION, + REQWEST_TIMEOUT, +}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use reqwest_middleware::ClientWithMiddleware; use serde::Deserialize; @@ -105,32 +110,75 @@ pub(crate) struct PictrsFile { delete_token: String, } +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct PictrsPurgeResponse { + msg: String, +} + #[tracing::instrument(skip_all)] pub(crate) async fn fetch_pictrs( client: &ClientWithMiddleware, settings: &Settings, image_url: &Url, ) -> Result { - if let Some(pictrs_url) = settings.pictrs_url.to_owned() { - is_image_content_type(client, image_url).await?; + let pictrs_config = settings.pictrs_config()?; + is_image_content_type(client, image_url).await?; - let fetch_url = format!( - "{}/image/download?url={}", - pictrs_url, - utf8_percent_encode(image_url.as_str(), NON_ALPHANUMERIC) // TODO this might not be needed - ); + let fetch_url = format!( + "{}/image/download?url={}", + pictrs_config.url, + utf8_percent_encode(image_url.as_str(), NON_ALPHANUMERIC) // TODO this might not be needed + ); - let response = client.get(&fetch_url).send().await?; + let response = client + .get(&fetch_url) + .timeout(REQWEST_TIMEOUT) + .send() + .await?; - let response: PictrsResponse = response.json().await.map_err(LemmyError::from)?; + let response: PictrsResponse = response.json().await.map_err(LemmyError::from)?; - if response.msg == "ok" { - Ok(response) - } else { - Err(LemmyError::from_message(&response.msg)) - } + if response.msg == "ok" { + Ok(response) + } else { + Err(LemmyError::from_message(&response.msg)) + } +} + +/// Purges an image from pictrs +/// Note: This should often be coerced from a Result to .ok() in order to fail softly, because: +/// - It might fail due to image being not local +/// - It might not be an image +/// - Pictrs might not be set up +pub async fn purge_image_from_pictrs( + client: &ClientWithMiddleware, + settings: &Settings, + image_url: &Url, +) -> Result<(), LemmyError> { + let pictrs_config = settings.pictrs_config()?; + is_image_content_type(client, image_url).await?; + + let alias = image_url + .path_segments() + .ok_or_else(|| LemmyError::from_message("Image URL missing path segments"))? + .next_back() + .ok_or_else(|| LemmyError::from_message("Image URL missing last path segment"))?; + + let purge_url = format!("{}/internal/purge?alias={}", pictrs_config.url, alias); + + let response = client + .post(&purge_url) + .timeout(REQWEST_TIMEOUT) + .header("x-api-token", pictrs_config.api_key) + .send() + .await?; + + let response: PictrsPurgeResponse = response.json().await.map_err(LemmyError::from)?; + + if response.msg == "ok" { + Ok(()) } else { - Err(LemmyError::from_message("pictrs_url not set up in config")) + Err(LemmyError::from_message(&response.msg)) } } diff --git a/crates/api_common/src/site.rs b/crates/api_common/src/site.rs index 71b3d0de..f61d3405 100644 --- a/crates/api_common/src/site.rs +++ b/crates/api_common/src/site.rs @@ -1,6 +1,6 @@ use crate::sensitive::Sensitive; use lemmy_db_schema::{ - newtypes::{CommunityId, PersonId}, + newtypes::{CommentId, CommunityId, PersonId, PostId}, ListingType, SearchType, SortType, @@ -21,6 +21,10 @@ use lemmy_db_views_actor::structs::{ PersonViewSafe, }; use lemmy_db_views_moderator::structs::{ + AdminPurgeCommentView, + AdminPurgeCommunityView, + AdminPurgePersonView, + AdminPurgePostView, ModAddCommunityView, ModAddView, ModBanFromCommunityView, @@ -93,6 +97,10 @@ pub struct GetModlogResponse { pub added_to_community: Vec, pub transferred_to_community: Vec, pub added: Vec, + pub admin_purged_persons: Vec, + pub admin_purged_communities: Vec, + pub admin_purged_posts: Vec, + pub admin_purged_comments: Vec, pub hidden_communities: Vec, } @@ -194,6 +202,39 @@ pub struct FederatedInstances { pub blocked: Option>, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PurgePerson { + pub person_id: PersonId, + pub reason: Option, + pub auth: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PurgeCommunity { + pub community_id: CommunityId, + pub reason: Option, + pub auth: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PurgePost { + pub post_id: PostId, + pub reason: Option, + pub auth: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PurgeComment { + pub comment_id: CommentId, + pub reason: Option, + pub auth: String, +} + +#[derive(Serialize, Deserialize)] +pub struct PurgeItemResponse { + pub success: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct ListRegistrationApplications { /// Only shows the unread applications (IE those without an admin actor) diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index f8a25251..e96325cf 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -1,4 +1,4 @@ -use crate::{sensitive::Sensitive, site::FederatedInstances}; +use crate::{request::purge_image_from_pictrs, sensitive::Sensitive, site::FederatedInstances}; use lemmy_db_schema::{ newtypes::{CommunityId, LocalUserId, PersonId, PostId}, source::{ @@ -32,6 +32,7 @@ use lemmy_utils::{ settings::structs::Settings, utils::generate_random_string, }; +use reqwest_middleware::ClientWithMiddleware; use rosetta_i18n::{Language, LanguageId}; use tracing::warn; @@ -505,13 +506,98 @@ pub async fn check_private_instance_and_federation_enabled( Ok(()) } -pub async fn remove_user_data(banned_person_id: PersonId, pool: &DbPool) -> Result<(), LemmyError> { +pub async fn purge_image_posts_for_person( + banned_person_id: PersonId, + pool: &DbPool, + settings: &Settings, + client: &ClientWithMiddleware, +) -> Result<(), LemmyError> { + let posts = blocking(pool, move |conn: &'_ _| { + Post::fetch_pictrs_posts_for_creator(conn, banned_person_id) + }) + .await??; + for post in posts { + if let Some(url) = post.url { + purge_image_from_pictrs(client, settings, &url).await.ok(); + } + if let Some(thumbnail_url) = post.thumbnail_url { + purge_image_from_pictrs(client, settings, &thumbnail_url) + .await + .ok(); + } + } + + blocking(pool, move |conn| { + Post::remove_pictrs_post_images_and_thumbnails_for_creator(conn, banned_person_id) + }) + .await??; + + Ok(()) +} + +pub async fn purge_image_posts_for_community( + banned_community_id: CommunityId, + pool: &DbPool, + settings: &Settings, + client: &ClientWithMiddleware, +) -> Result<(), LemmyError> { + let posts = blocking(pool, move |conn: &'_ _| { + Post::fetch_pictrs_posts_for_community(conn, banned_community_id) + }) + .await??; + for post in posts { + if let Some(url) = post.url { + purge_image_from_pictrs(client, settings, &url).await.ok(); + } + if let Some(thumbnail_url) = post.thumbnail_url { + purge_image_from_pictrs(client, settings, &thumbnail_url) + .await + .ok(); + } + } + + blocking(pool, move |conn| { + Post::remove_pictrs_post_images_and_thumbnails_for_community(conn, banned_community_id) + }) + .await??; + + Ok(()) +} + +pub async fn remove_user_data( + banned_person_id: PersonId, + pool: &DbPool, + settings: &Settings, + client: &ClientWithMiddleware, +) -> Result<(), LemmyError> { + // Purge user images + let person = blocking(pool, move |conn| Person::read(conn, banned_person_id)).await??; + if let Some(avatar) = person.avatar { + purge_image_from_pictrs(client, settings, &avatar) + .await + .ok(); + } + if let Some(banner) = person.banner { + purge_image_from_pictrs(client, settings, &banner) + .await + .ok(); + } + + // Update the fields to None + blocking(pool, move |conn| { + Person::remove_avatar_and_banner(conn, banned_person_id) + }) + .await??; + // Posts blocking(pool, move |conn: &'_ _| { Post::update_removed_for_creator(conn, banned_person_id, None, true) }) .await??; + // Purge image posts + purge_image_posts_for_person(banned_person_id, pool, settings, client).await?; + // Communities // Remove all communities where they're the top mod // for now, remove the communities manually @@ -527,8 +613,24 @@ pub async fn remove_user_data(banned_person_id: PersonId, pool: &DbPool) -> Resu .collect(); for first_mod_community in banned_user_first_communities { + let community_id = first_mod_community.community.id; blocking(pool, move |conn: &'_ _| { - Community::update_removed(conn, first_mod_community.community.id, true) + Community::update_removed(conn, community_id, true) + }) + .await??; + + // Delete the community images + if let Some(icon) = first_mod_community.community.icon { + purge_image_from_pictrs(client, settings, &icon).await.ok(); + } + if let Some(banner) = first_mod_community.community.banner { + purge_image_from_pictrs(client, settings, &banner) + .await + .ok(); + } + // Update the fields to None + blocking(pool, move |conn| { + Community::remove_avatar_and_banner(conn, community_id) }) .await??; } @@ -575,7 +677,26 @@ pub async fn remove_user_data_in_community( Ok(()) } -pub async fn delete_user_account(person_id: PersonId, pool: &DbPool) -> Result<(), LemmyError> { +pub async fn delete_user_account( + person_id: PersonId, + pool: &DbPool, + settings: &Settings, + client: &ClientWithMiddleware, +) -> Result<(), LemmyError> { + // Delete their images + let person = blocking(pool, move |conn| Person::read(conn, person_id)).await??; + if let Some(avatar) = person.avatar { + purge_image_from_pictrs(client, settings, &avatar) + .await + .ok(); + } + if let Some(banner) = person.banner { + purge_image_from_pictrs(client, settings, &banner) + .await + .ok(); + } + // No need to update avatar and banner, those are handled in Person::delete_account + // Comments let permadelete = move |conn: &'_ _| Comment::permadelete_for_creator(conn, person_id); blocking(pool, permadelete) @@ -588,6 +709,9 @@ pub async fn delete_user_account(person_id: PersonId, pool: &DbPool) -> Result<( .await? .map_err(|e| LemmyError::from_error_message(e, "couldnt_update_post"))?; + // Purge image posts + purge_image_posts_for_person(person_id, pool, settings, client).await?; + blocking(pool, move |conn| Person::delete_account(conn, person_id)).await??; Ok(()) diff --git a/crates/api_crud/src/user/delete.rs b/crates/api_crud/src/user/delete.rs index bde403a5..7273d990 100644 --- a/crates/api_crud/src/user/delete.rs +++ b/crates/api_crud/src/user/delete.rs @@ -33,7 +33,13 @@ impl PerformCrud for DeleteAccount { return Err(LemmyError::from_message("password_incorrect")); } - delete_user_account(local_user_view.person.id, context.pool()).await?; + delete_user_account( + local_user_view.person.id, + context.pool(), + &context.settings(), + context.client(), + ) + .await?; DeleteUser::send(&local_user_view.person.into(), context).await?; Ok(DeleteAccountResponse {}) diff --git a/crates/apub/src/activities/block/block_user.rs b/crates/apub/src/activities/block/block_user.rs index 2166259c..32feea60 100644 --- a/crates/apub/src/activities/block/block_user.rs +++ b/crates/apub/src/activities/block/block_user.rs @@ -181,7 +181,13 @@ impl ActivityHandler for BlockUser { }) .await??; if self.remove_data.unwrap_or(false) { - remove_user_data(blocked_person.id, context.pool()).await?; + remove_user_data( + blocked_person.id, + context.pool(), + &context.settings(), + context.client(), + ) + .await?; } // write mod log diff --git a/crates/apub/src/activities/deletion/delete_user.rs b/crates/apub/src/activities/deletion/delete_user.rs index 771d33eb..0e342bd1 100644 --- a/crates/apub/src/activities/deletion/delete_user.rs +++ b/crates/apub/src/activities/deletion/delete_user.rs @@ -51,7 +51,13 @@ impl ActivityHandler for DeleteUser { .actor .dereference(context, local_instance(context), request_counter) .await?; - delete_user_account(actor.id, context.pool()).await?; + delete_user_account( + actor.id, + context.pool(), + &context.settings(), + context.client(), + ) + .await?; Ok(()) } } diff --git a/crates/db_schema/src/impls/community.rs b/crates/db_schema/src/impls/community.rs index 14a670f5..dc539c69 100644 --- a/crates/db_schema/src/impls/community.rs +++ b/crates/db_schema/src/impls/community.rs @@ -138,6 +138,19 @@ impl Community { .set(community_form) .get_result::(conn) } + + pub fn remove_avatar_and_banner( + conn: &PgConnection, + community_id: CommunityId, + ) -> Result { + use crate::schema::community::dsl::*; + diesel::update(community.find(community_id)) + .set(( + icon.eq::>(None), + banner.eq::>(None), + )) + .get_result::(conn) + } } impl Joinable for CommunityModerator { diff --git a/crates/db_schema/src/impls/moderator.rs b/crates/db_schema/src/impls/moderator.rs index 36582d10..d4323735 100644 --- a/crates/db_schema/src/impls/moderator.rs +++ b/crates/db_schema/src/impls/moderator.rs @@ -263,6 +263,98 @@ impl Crud for ModAdd { } } +impl Crud for AdminPurgePerson { + type Form = AdminPurgePersonForm; + type IdType = i32; + fn read(conn: &PgConnection, from_id: i32) -> Result { + use crate::schema::admin_purge_person::dsl::*; + admin_purge_person.find(from_id).first::(conn) + } + + fn create(conn: &PgConnection, form: &Self::Form) -> Result { + use crate::schema::admin_purge_person::dsl::*; + insert_into(admin_purge_person) + .values(form) + .get_result::(conn) + } + + fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result { + use crate::schema::admin_purge_person::dsl::*; + diesel::update(admin_purge_person.find(from_id)) + .set(form) + .get_result::(conn) + } +} + +impl Crud for AdminPurgeCommunity { + type Form = AdminPurgeCommunityForm; + type IdType = i32; + fn read(conn: &PgConnection, from_id: i32) -> Result { + use crate::schema::admin_purge_community::dsl::*; + admin_purge_community.find(from_id).first::(conn) + } + + fn create(conn: &PgConnection, form: &Self::Form) -> Result { + use crate::schema::admin_purge_community::dsl::*; + insert_into(admin_purge_community) + .values(form) + .get_result::(conn) + } + + fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result { + use crate::schema::admin_purge_community::dsl::*; + diesel::update(admin_purge_community.find(from_id)) + .set(form) + .get_result::(conn) + } +} + +impl Crud for AdminPurgePost { + type Form = AdminPurgePostForm; + type IdType = i32; + fn read(conn: &PgConnection, from_id: i32) -> Result { + use crate::schema::admin_purge_post::dsl::*; + admin_purge_post.find(from_id).first::(conn) + } + + fn create(conn: &PgConnection, form: &Self::Form) -> Result { + use crate::schema::admin_purge_post::dsl::*; + insert_into(admin_purge_post) + .values(form) + .get_result::(conn) + } + + fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result { + use crate::schema::admin_purge_post::dsl::*; + diesel::update(admin_purge_post.find(from_id)) + .set(form) + .get_result::(conn) + } +} + +impl Crud for AdminPurgeComment { + type Form = AdminPurgeCommentForm; + type IdType = i32; + fn read(conn: &PgConnection, from_id: i32) -> Result { + use crate::schema::admin_purge_comment::dsl::*; + admin_purge_comment.find(from_id).first::(conn) + } + + fn create(conn: &PgConnection, form: &Self::Form) -> Result { + use crate::schema::admin_purge_comment::dsl::*; + insert_into(admin_purge_comment) + .values(form) + .get_result::(conn) + } + + fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result { + use crate::schema::admin_purge_comment::dsl::*; + diesel::update(admin_purge_comment.find(from_id)) + .set(form) + .get_result::(conn) + } +} + #[cfg(test)] mod tests { use crate::{ diff --git a/crates/db_schema/src/impls/person.rs b/crates/db_schema/src/impls/person.rs index c59126c3..1389544e 100644 --- a/crates/db_schema/src/impls/person.rs +++ b/crates/db_schema/src/impls/person.rs @@ -228,6 +228,8 @@ impl Person { diesel::update(person.find(person_id)) .set(( display_name.eq::>(None), + avatar.eq::>(None), + banner.eq::>(None), bio.eq::>(None), matrix_user_id.eq::>(None), deleted.eq(true), @@ -265,6 +267,15 @@ impl Person { .set(admin.eq(false)) .get_result::(conn) } + + pub fn remove_avatar_and_banner(conn: &PgConnection, person_id: PersonId) -> Result { + diesel::update(person.find(person_id)) + .set(( + avatar.eq::>(None), + banner.eq::>(None), + )) + .get_result::(conn) + } } impl PersonSafe { diff --git a/crates/db_schema/src/impls/post.rs b/crates/db_schema/src/impls/post.rs index 03a4b719..99967341 100644 --- a/crates/db_schema/src/impls/post.rs +++ b/crates/db_schema/src/impls/post.rs @@ -13,7 +13,7 @@ use crate::{ traits::{Crud, DeleteableOrRemoveable, Likeable, Readable, Saveable}, utils::naive_now, }; -use diesel::{dsl::*, result::Error, ExpressionMethods, PgConnection, QueryDsl, RunQueryDsl}; +use diesel::{dsl::*, result::Error, ExpressionMethods, PgConnection, QueryDsl, RunQueryDsl, *}; use url::Url; impl Crud for Post { @@ -174,6 +174,71 @@ impl Post { .map(Into::into), ) } + + pub fn fetch_pictrs_posts_for_creator( + conn: &PgConnection, + for_creator_id: PersonId, + ) -> Result, Error> { + use crate::schema::post::dsl::*; + let pictrs_search = "%pictrs/image%"; + + post + .filter(creator_id.eq(for_creator_id)) + .filter(url.like(pictrs_search)) + .load::(conn) + } + + /// Sets the url and thumbnails fields to None + pub fn remove_pictrs_post_images_and_thumbnails_for_creator( + conn: &PgConnection, + for_creator_id: PersonId, + ) -> Result, Error> { + use crate::schema::post::dsl::*; + let pictrs_search = "%pictrs/image%"; + + diesel::update( + post + .filter(creator_id.eq(for_creator_id)) + .filter(url.like(pictrs_search)), + ) + .set(( + url.eq::>(None), + thumbnail_url.eq::>(None), + )) + .get_results::(conn) + } + + pub fn fetch_pictrs_posts_for_community( + conn: &PgConnection, + for_community_id: CommunityId, + ) -> Result, Error> { + use crate::schema::post::dsl::*; + let pictrs_search = "%pictrs/image%"; + post + .filter(community_id.eq(for_community_id)) + .filter(url.like(pictrs_search)) + .load::(conn) + } + + /// Sets the url and thumbnails fields to None + pub fn remove_pictrs_post_images_and_thumbnails_for_community( + conn: &PgConnection, + for_community_id: CommunityId, + ) -> Result, Error> { + use crate::schema::post::dsl::*; + let pictrs_search = "%pictrs/image%"; + + diesel::update( + post + .filter(community_id.eq(for_community_id)) + .filter(url.like(pictrs_search)), + ) + .set(( + url.eq::>(None), + thumbnail_url.eq::>(None), + )) + .get_results::(conn) + } } impl Likeable for PostLike { diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 6146b9e3..91ad7278 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -577,6 +577,16 @@ table! { } } +table! { + admin_purge_comment (id) { + id -> Int4, + admin_person_id -> Int4, + post_id -> Int4, + reason -> Nullable, + when_ -> Timestamp, + } +} + table! { email_verification (id) { id -> Int4, @@ -587,6 +597,34 @@ table! { } } +table! { + admin_purge_community (id) { + id -> Int4, + admin_person_id -> Int4, + reason -> Nullable, + when_ -> Timestamp, + } +} + +table! { + admin_purge_person (id) { + id -> Int4, + admin_person_id -> Int4, + reason -> Nullable, + when_ -> Timestamp, + } +} + +table! { + admin_purge_post (id) { + id -> Int4, + admin_person_id -> Int4, + community_id -> Int4, + reason -> Nullable, + when_ -> Timestamp, + } +} + table! { registration_application (id) { id -> Int4, @@ -675,6 +713,13 @@ joinable!(registration_application -> person (admin_id)); joinable!(mod_hide_community -> person (mod_person_id)); joinable!(mod_hide_community -> community (community_id)); +joinable!(admin_purge_comment -> person (admin_person_id)); +joinable!(admin_purge_comment -> post (post_id)); +joinable!(admin_purge_community -> person (admin_person_id)); +joinable!(admin_purge_person -> person (admin_person_id)); +joinable!(admin_purge_post -> community (community_id)); +joinable!(admin_purge_post -> person (admin_person_id)); + allow_tables_to_appear_in_same_query!( activity, comment, @@ -718,6 +763,10 @@ allow_tables_to_appear_in_same_query!( comment_alias_1, person_alias_1, person_alias_2, + admin_purge_comment, + admin_purge_community, + admin_purge_person, + admin_purge_post, email_verification, registration_application ); diff --git a/crates/db_schema/src/source/moderator.rs b/crates/db_schema/src/source/moderator.rs index b617564b..7f13eaa9 100644 --- a/crates/db_schema/src/source/moderator.rs +++ b/crates/db_schema/src/source/moderator.rs @@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "full")] use crate::schema::{ + admin_purge_comment, + admin_purge_community, + admin_purge_person, + admin_purge_post, mod_add, mod_add_community, mod_ban, @@ -247,3 +251,75 @@ pub struct ModAddForm { pub other_person_id: PersonId, pub removed: Option, } + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Queryable, Identifiable))] +#[cfg_attr(feature = "full", table_name = "admin_purge_person")] +pub struct AdminPurgePerson { + pub id: i32, + pub admin_person_id: PersonId, + pub reason: Option, + pub when_: chrono::NaiveDateTime, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", table_name = "admin_purge_person")] +pub struct AdminPurgePersonForm { + pub admin_person_id: PersonId, + pub reason: Option, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Queryable, Identifiable))] +#[cfg_attr(feature = "full", table_name = "admin_purge_community")] +pub struct AdminPurgeCommunity { + pub id: i32, + pub admin_person_id: PersonId, + pub reason: Option, + pub when_: chrono::NaiveDateTime, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", table_name = "admin_purge_community")] +pub struct AdminPurgeCommunityForm { + pub admin_person_id: PersonId, + pub reason: Option, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Queryable, Identifiable))] +#[cfg_attr(feature = "full", table_name = "admin_purge_post")] +pub struct AdminPurgePost { + pub id: i32, + pub admin_person_id: PersonId, + pub community_id: CommunityId, + pub reason: Option, + pub when_: chrono::NaiveDateTime, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", table_name = "admin_purge_post")] +pub struct AdminPurgePostForm { + pub admin_person_id: PersonId, + pub community_id: CommunityId, + pub reason: Option, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(Queryable, Identifiable))] +#[cfg_attr(feature = "full", table_name = "admin_purge_comment")] +pub struct AdminPurgeComment { + pub id: i32, + pub admin_person_id: PersonId, + pub post_id: PostId, + pub reason: Option, + pub when_: chrono::NaiveDateTime, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", table_name = "admin_purge_comment")] +pub struct AdminPurgeCommentForm { + pub admin_person_id: PersonId, + pub post_id: PostId, + pub reason: Option, +} diff --git a/crates/db_views_moderator/src/admin_purge_comment_view.rs b/crates/db_views_moderator/src/admin_purge_comment_view.rs new file mode 100644 index 00000000..b8c8f745 --- /dev/null +++ b/crates/db_views_moderator/src/admin_purge_comment_view.rs @@ -0,0 +1,62 @@ +use crate::structs::AdminPurgeCommentView; +use diesel::{result::Error, *}; +use lemmy_db_schema::{ + newtypes::PersonId, + schema::{admin_purge_comment, person, post}, + source::{ + moderator::AdminPurgeComment, + person::{Person, PersonSafe}, + post::Post, + }, + traits::{ToSafe, ViewToVec}, + utils::limit_and_offset, +}; + +type AdminPurgeCommentViewTuple = (AdminPurgeComment, PersonSafe, Post); + +impl AdminPurgeCommentView { + pub fn list( + conn: &PgConnection, + admin_person_id: Option, + page: Option, + limit: Option, + ) -> Result, Error> { + let mut query = admin_purge_comment::table + .inner_join(person::table.on(admin_purge_comment::admin_person_id.eq(person::id))) + .inner_join(post::table) + .select(( + admin_purge_comment::all_columns, + Person::safe_columns_tuple(), + post::all_columns, + )) + .into_boxed(); + + if let Some(admin_person_id) = admin_person_id { + query = query.filter(admin_purge_comment::admin_person_id.eq(admin_person_id)); + }; + + let (limit, offset) = limit_and_offset(page, limit); + + let res = query + .limit(limit) + .offset(offset) + .order_by(admin_purge_comment::when_.desc()) + .load::(conn)?; + + Ok(Self::from_tuple_to_vec(res)) + } +} + +impl ViewToVec for AdminPurgeCommentView { + type DbTuple = AdminPurgeCommentViewTuple; + fn from_tuple_to_vec(items: Vec) -> Vec { + items + .iter() + .map(|a| Self { + admin_purge_comment: a.0.to_owned(), + admin: a.1.to_owned(), + post: a.2.to_owned(), + }) + .collect::>() + } +} diff --git a/crates/db_views_moderator/src/admin_purge_community_view.rs b/crates/db_views_moderator/src/admin_purge_community_view.rs new file mode 100644 index 00000000..0c40a396 --- /dev/null +++ b/crates/db_views_moderator/src/admin_purge_community_view.rs @@ -0,0 +1,58 @@ +use crate::structs::AdminPurgeCommunityView; +use diesel::{result::Error, *}; +use lemmy_db_schema::{ + newtypes::PersonId, + schema::{admin_purge_community, person}, + source::{ + moderator::AdminPurgeCommunity, + person::{Person, PersonSafe}, + }, + traits::{ToSafe, ViewToVec}, + utils::limit_and_offset, +}; + +type AdminPurgeCommunityViewTuple = (AdminPurgeCommunity, PersonSafe); + +impl AdminPurgeCommunityView { + pub fn list( + conn: &PgConnection, + admin_person_id: Option, + page: Option, + limit: Option, + ) -> Result, Error> { + let mut query = admin_purge_community::table + .inner_join(person::table.on(admin_purge_community::admin_person_id.eq(person::id))) + .select(( + admin_purge_community::all_columns, + Person::safe_columns_tuple(), + )) + .into_boxed(); + + if let Some(admin_person_id) = admin_person_id { + query = query.filter(admin_purge_community::admin_person_id.eq(admin_person_id)); + }; + + let (limit, offset) = limit_and_offset(page, limit); + + let res = query + .limit(limit) + .offset(offset) + .order_by(admin_purge_community::when_.desc()) + .load::(conn)?; + + Ok(Self::from_tuple_to_vec(res)) + } +} + +impl ViewToVec for AdminPurgeCommunityView { + type DbTuple = AdminPurgeCommunityViewTuple; + fn from_tuple_to_vec(items: Vec) -> Vec { + items + .iter() + .map(|a| Self { + admin_purge_community: a.0.to_owned(), + admin: a.1.to_owned(), + }) + .collect::>() + } +} diff --git a/crates/db_views_moderator/src/admin_purge_person_view.rs b/crates/db_views_moderator/src/admin_purge_person_view.rs new file mode 100644 index 00000000..9828e432 --- /dev/null +++ b/crates/db_views_moderator/src/admin_purge_person_view.rs @@ -0,0 +1,58 @@ +use crate::structs::AdminPurgePersonView; +use diesel::{result::Error, *}; +use lemmy_db_schema::{ + newtypes::PersonId, + schema::{admin_purge_person, person}, + source::{ + moderator::AdminPurgePerson, + person::{Person, PersonSafe}, + }, + traits::{ToSafe, ViewToVec}, + utils::limit_and_offset, +}; + +type AdminPurgePersonViewTuple = (AdminPurgePerson, PersonSafe); + +impl AdminPurgePersonView { + pub fn list( + conn: &PgConnection, + admin_person_id: Option, + page: Option, + limit: Option, + ) -> Result, Error> { + let mut query = admin_purge_person::table + .inner_join(person::table.on(admin_purge_person::admin_person_id.eq(person::id))) + .select(( + admin_purge_person::all_columns, + Person::safe_columns_tuple(), + )) + .into_boxed(); + + if let Some(admin_person_id) = admin_person_id { + query = query.filter(admin_purge_person::admin_person_id.eq(admin_person_id)); + }; + + let (limit, offset) = limit_and_offset(page, limit); + + let res = query + .limit(limit) + .offset(offset) + .order_by(admin_purge_person::when_.desc()) + .load::(conn)?; + + Ok(Self::from_tuple_to_vec(res)) + } +} + +impl ViewToVec for AdminPurgePersonView { + type DbTuple = AdminPurgePersonViewTuple; + fn from_tuple_to_vec(items: Vec) -> Vec { + items + .iter() + .map(|a| Self { + admin_purge_person: a.0.to_owned(), + admin: a.1.to_owned(), + }) + .collect::>() + } +} diff --git a/crates/db_views_moderator/src/admin_purge_post_view.rs b/crates/db_views_moderator/src/admin_purge_post_view.rs new file mode 100644 index 00000000..6665e467 --- /dev/null +++ b/crates/db_views_moderator/src/admin_purge_post_view.rs @@ -0,0 +1,62 @@ +use crate::structs::AdminPurgePostView; +use diesel::{result::Error, *}; +use lemmy_db_schema::{ + newtypes::PersonId, + schema::{admin_purge_post, community, person}, + source::{ + community::{Community, CommunitySafe}, + moderator::AdminPurgePost, + person::{Person, PersonSafe}, + }, + traits::{ToSafe, ViewToVec}, + utils::limit_and_offset, +}; + +type AdminPurgePostViewTuple = (AdminPurgePost, PersonSafe, CommunitySafe); + +impl AdminPurgePostView { + pub fn list( + conn: &PgConnection, + admin_person_id: Option, + page: Option, + limit: Option, + ) -> Result, Error> { + let mut query = admin_purge_post::table + .inner_join(person::table.on(admin_purge_post::admin_person_id.eq(person::id))) + .inner_join(community::table) + .select(( + admin_purge_post::all_columns, + Person::safe_columns_tuple(), + Community::safe_columns_tuple(), + )) + .into_boxed(); + + if let Some(admin_person_id) = admin_person_id { + query = query.filter(admin_purge_post::admin_person_id.eq(admin_person_id)); + }; + + let (limit, offset) = limit_and_offset(page, limit); + + let res = query + .limit(limit) + .offset(offset) + .order_by(admin_purge_post::when_.desc()) + .load::(conn)?; + + Ok(Self::from_tuple_to_vec(res)) + } +} + +impl ViewToVec for AdminPurgePostView { + type DbTuple = AdminPurgePostViewTuple; + fn from_tuple_to_vec(items: Vec) -> Vec { + items + .iter() + .map(|a| Self { + admin_purge_post: a.0.to_owned(), + admin: a.1.to_owned(), + community: a.2.to_owned(), + }) + .collect::>() + } +} diff --git a/crates/db_views_moderator/src/lib.rs b/crates/db_views_moderator/src/lib.rs index c6f32426..60d5c787 100644 --- a/crates/db_views_moderator/src/lib.rs +++ b/crates/db_views_moderator/src/lib.rs @@ -1,4 +1,12 @@ #[cfg(feature = "full")] +pub mod admin_purge_comment_view; +#[cfg(feature = "full")] +pub mod admin_purge_community_view; +#[cfg(feature = "full")] +pub mod admin_purge_person_view; +#[cfg(feature = "full")] +pub mod admin_purge_post_view; +#[cfg(feature = "full")] pub mod mod_add_community_view; #[cfg(feature = "full")] pub mod mod_add_view; diff --git a/crates/db_views_moderator/src/structs.rs b/crates/db_views_moderator/src/structs.rs index 9d525b57..ebe93399 100644 --- a/crates/db_views_moderator/src/structs.rs +++ b/crates/db_views_moderator/src/structs.rs @@ -2,6 +2,10 @@ use lemmy_db_schema::source::{ comment::Comment, community::CommunitySafe, moderator::{ + AdminPurgeComment, + AdminPurgeCommunity, + AdminPurgePerson, + AdminPurgePost, ModAdd, ModAddCommunity, ModBan, @@ -104,3 +108,29 @@ pub struct ModTransferCommunityView { pub community: CommunitySafe, pub modded_person: PersonSafeAlias1, } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AdminPurgeCommentView { + pub admin_purge_comment: AdminPurgeComment, + pub admin: PersonSafe, + pub post: Post, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AdminPurgeCommunityView { + pub admin_purge_community: AdminPurgeCommunity, + pub admin: PersonSafe, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AdminPurgePersonView { + pub admin_purge_person: AdminPurgePerson, + pub admin: PersonSafe, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AdminPurgePostView { + pub admin_purge_post: AdminPurgePost, + pub admin: PersonSafe, + pub community: CommunitySafe, +} diff --git a/crates/routes/src/images.rs b/crates/routes/src/images.rs index 23d2020b..edd8fc8d 100644 --- a/crates/routes/src/images.rs +++ b/crates/routes/src/images.rs @@ -10,9 +10,8 @@ use actix_web::{ HttpRequest, HttpResponse, }; -use anyhow::anyhow; use futures::stream::{Stream, StreamExt}; -use lemmy_utils::{claims::Claims, error::LemmyError, rate_limit::RateLimit}; +use lemmy_utils::{claims::Claims, rate_limit::RateLimit, REQWEST_TIMEOUT}; use lemmy_websocket::LemmyContext; use reqwest::Body; use reqwest_middleware::{ClientWithMiddleware, RequestBuilder}; @@ -28,7 +27,8 @@ pub fn config(cfg: &mut web::ServiceConfig, client: ClientWithMiddleware, rate_l ) // This has optional query params: /image/{filename}?format=jpg&thumbnail=256 .service(web::resource("/pictrs/image/{filename}").route(web::get().to(full_res))) - .service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete))); + .service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete))) + .service(web::resource("/pictrs/internal/purge").route(web::post().to(purge))); } #[derive(Debug, Serialize, Deserialize)] @@ -49,6 +49,14 @@ struct PictrsParams { thumbnail: Option, } +#[derive(Deserialize)] +enum PictrsPurgeParams { + #[serde(rename = "file")] + File(String), + #[serde(rename = "alias")] + Alias(String), +} + fn adapt_request( request: &HttpRequest, client: &ClientWithMiddleware, @@ -57,7 +65,9 @@ fn adapt_request( // remove accept-encoding header so that pictrs doesnt compress the response const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST]; - let client_request = client.request(request.method().clone(), url); + let client_request = client + .request(request.method().clone(), url) + .timeout(REQWEST_TIMEOUT); request .headers() @@ -86,7 +96,8 @@ async fn upload( return Ok(HttpResponse::Unauthorized().finish()); }; - let image_url = format!("{}/image", pictrs_url(context.settings().pictrs_url)?); + let pictrs_config = context.settings().pictrs_config()?; + let image_url = format!("{}/image", pictrs_config.url); let mut client_req = adapt_request(&req, &client, image_url); @@ -116,22 +127,16 @@ async fn full_res( let name = &filename.into_inner(); // If there are no query params, the URL is original - let pictrs_url_settings = context.settings().pictrs_url; + let pictrs_config = context.settings().pictrs_config()?; let url = if params.format.is_none() && params.thumbnail.is_none() { - format!( - "{}/image/original/{}", - pictrs_url(pictrs_url_settings)?, - name, - ) + format!("{}/image/original/{}", pictrs_config.url, name,) } else { // Use jpg as a default when none is given let format = params.format.unwrap_or_else(|| "jpg".to_string()); let mut url = format!( "{}/image/process.{}?src={}", - pictrs_url(pictrs_url_settings)?, - format, - name, + pictrs_config.url, format, name, ); if let Some(size) = params.thumbnail { @@ -181,12 +186,8 @@ async fn delete( ) -> Result { let (token, file) = components.into_inner(); - let url = format!( - "{}/image/delete/{}/{}", - pictrs_url(context.settings().pictrs_url)?, - &token, - &file - ); + let pictrs_config = context.settings().pictrs_config()?; + let url = format!("{}/image/delete/{}/{}", pictrs_config.url, &token, &file); let mut client_req = adapt_request(&req, &client, url); @@ -199,8 +200,32 @@ async fn delete( Ok(HttpResponse::build(res.status()).body(BodyStream::new(res.bytes_stream()))) } -fn pictrs_url(pictrs_url: Option) -> Result { - pictrs_url.ok_or_else(|| anyhow!("images_disabled").into()) +async fn purge( + web::Query(params): web::Query, + req: HttpRequest, + client: web::Data, + context: web::Data, +) -> Result { + let purge_string = match params { + PictrsPurgeParams::File(f) => format!("file={}", f), + PictrsPurgeParams::Alias(a) => format!("alias={}", a), + }; + + let pictrs_config = context.settings().pictrs_config()?; + let url = format!("{}/internal/purge?{}", pictrs_config.url, &purge_string); + + let mut client_req = adapt_request(&req, &client, url); + + if let Some(addr) = req.head().peer_addr { + client_req = client_req.header("X-Forwarded-For", addr.to_string()) + } + + // Add the API token, X-Api-Token header + client_req = client_req.header("x-api-token", pictrs_config.api_key); + + let res = client_req.send().await.map_err(error::ErrorBadRequest)?; + + Ok(HttpResponse::build(res.status()).body(BodyStream::new(res.bytes_stream()))) } fn make_send(mut stream: S) -> impl Stream + Send + Unpin + 'static diff --git a/crates/utils/src/settings/mod.rs b/crates/utils/src/settings/mod.rs index 50597f24..513f7ded 100644 --- a/crates/utils/src/settings/mod.rs +++ b/crates/utils/src/settings/mod.rs @@ -1,4 +1,8 @@ -use crate::{error::LemmyError, location_info, settings::structs::Settings}; +use crate::{ + error::LemmyError, + location_info, + settings::structs::{PictrsConfig, Settings}, +}; use anyhow::{anyhow, Context}; use deser_hjson::from_str; use once_cell::sync::Lazy; @@ -116,4 +120,11 @@ impl Settings { .expect("compile regex") }) } + + pub fn pictrs_config(&self) -> Result { + self + .pictrs_config + .to_owned() + .ok_or_else(|| anyhow!("images_disabled").into()) + } } diff --git a/crates/utils/src/settings/structs.rs b/crates/utils/src/settings/structs.rs index b703e032..d6846f18 100644 --- a/crates/utils/src/settings/structs.rs +++ b/crates/utils/src/settings/structs.rs @@ -14,6 +14,9 @@ pub struct Settings { /// Settings related to activitypub federation #[default(FederationConfig::default())] pub federation: FederationConfig, + /// Pictrs image server configuration. + #[default(None)] + pub(crate) pictrs_config: Option, #[default(CaptchaConfig::default())] pub captcha: CaptchaConfig, /// Email sending configuration. All options except login/password are mandatory @@ -36,12 +39,9 @@ pub struct Settings { /// Whether the site is available over TLS. Needs to be true for federation to work. #[default(true)] pub tls_enabled: bool, - /// Address where pictrs is available (for image hosting) - #[default(None)] - #[doku(example = "http://localhost:8080")] - pub pictrs_url: Option, #[default(None)] #[doku(example = "(\\bThis\\b)|(\\bis\\b)|(\\bsample\\b)")] + /// A regex list of slurs to block / hide pub slur_filter: Option, /// Maximum length of local community and user names #[default(20)] @@ -56,6 +56,18 @@ pub struct Settings { pub opentelemetry_url: Option, } +#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] +#[serde(default)] +pub struct PictrsConfig { + /// Address where pictrs is available (for image hosting) + #[default("http://pictrs:8080")] + pub url: String, + + /// Set a custom pictrs API key. ( Required for deleting images ) + #[default("API_KEY")] + pub api_key: String, +} + #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default)] pub struct CaptchaConfig { diff --git a/crates/websocket/src/lib.rs b/crates/websocket/src/lib.rs index 2a7d1e8f..bac223b6 100644 --- a/crates/websocket/src/lib.rs +++ b/crates/websocket/src/lib.rs @@ -142,6 +142,10 @@ pub enum UserOperation { GetSiteMetadata, BlockCommunity, BlockPerson, + PurgePerson, + PurgeCommunity, + PurgePost, + PurgeComment, } #[derive(EnumString, Display, Debug, Clone)] diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index b3f1201e..737e926e 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -56,8 +56,10 @@ services: user: 991:991 environment: - PICTRS_OPENTELEMETRY_URL=http://otel:4137 + - PICTRS__API_KEY=API_KEY ports: - "6670:6669" + - "8080:8080" volumes: - ./volumes/pictrs:/mnt restart: always diff --git a/docker/lemmy.hjson b/docker/lemmy.hjson index edf37568..585f1446 100644 --- a/docker/lemmy.hjson +++ b/docker/lemmy.hjson @@ -20,8 +20,10 @@ # port where lemmy should listen for incoming requests port: 8536 # settings related to the postgresql database - # address where pictrs is available - pictrs_url: "http://pictrs:8080" + pictrs_config: { + url: "http://pictrs:8080" + api_key: "API_KEY" + } database: { # name of the postgres database for lemmy database: "lemmy" diff --git a/migrations/2021-10-01-141650_create_admin_purge/down.sql b/migrations/2021-10-01-141650_create_admin_purge/down.sql new file mode 100644 index 00000000..054923f6 --- /dev/null +++ b/migrations/2021-10-01-141650_create_admin_purge/down.sql @@ -0,0 +1,4 @@ +drop table admin_purge_person; +drop table admin_purge_community; +drop table admin_purge_post; +drop table admin_purge_comment; diff --git a/migrations/2021-10-01-141650_create_admin_purge/up.sql b/migrations/2021-10-01-141650_create_admin_purge/up.sql new file mode 100644 index 00000000..1eb6b3f1 --- /dev/null +++ b/migrations/2021-10-01-141650_create_admin_purge/up.sql @@ -0,0 +1,31 @@ +-- Add the admin_purge tables + +create table admin_purge_person ( + id serial primary key, + admin_person_id int references person on update cascade on delete cascade not null, + reason text, + when_ timestamp not null default now() +); + +create table admin_purge_community ( + id serial primary key, + admin_person_id int references person on update cascade on delete cascade not null, + reason text, + when_ timestamp not null default now() +); + +create table admin_purge_post ( + id serial primary key, + admin_person_id int references person on update cascade on delete cascade not null, + community_id int references community on update cascade on delete cascade not null, + reason text, + when_ timestamp not null default now() +); + +create table admin_purge_comment ( + id serial primary key, + admin_person_id int references person on update cascade on delete cascade not null, + post_id int references post on update cascade on delete cascade not null, + reason text, + when_ timestamp not null default now() +); diff --git a/src/api_routes.rs b/src/api_routes.rs index 36310386..c9ff8803 100644 --- a/src/api_routes.rs +++ b/src/api_routes.rs @@ -232,6 +232,14 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) { "/registration_application/approve", web::put().to(route_post::), ), + ) + .service( + web::scope("/admin/purge") + .wrap(rate_limit.message()) + .route("/person", web::post().to(route_post::)) + .route("/community", web::post().to(route_post::)) + .route("/post", web::post().to(route_post::)) + .route("/comment", web::post().to(route_post::)), ), ); } diff --git a/src/main.rs b/src/main.rs index 7f5946f5..3fbd0c65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,7 +99,7 @@ async fn main() -> Result<(), LemmyError> { settings.bind, settings.port ); - let client = Client::builder() + let reqwest_client = Client::builder() .user_agent(build_user_agent(&settings)) .timeout(REQWEST_TIMEOUT) .build()?; @@ -111,11 +111,16 @@ async fn main() -> Result<(), LemmyError> { backoff_exponent: 2, }; - let client = ClientBuilder::new(client) + let client = ClientBuilder::new(reqwest_client.clone()) .with(TracingMiddleware) .with(RetryTransientMiddleware::new_with_policy(retry_policy)) .build(); + // Pictrs cannot use the retry middleware + let pictrs_client = ClientBuilder::new(reqwest_client.clone()) + .with(TracingMiddleware) + .build(); + check_private_instance_and_federation_enabled(&pool, &settings).await?; let chat_server = ChatServer::startup( @@ -149,7 +154,7 @@ async fn main() -> Result<(), LemmyError> { .configure(|cfg| api_routes::config(cfg, &rate_limiter)) .configure(|cfg| lemmy_apub::http::routes::config(cfg, &settings)) .configure(feeds::config) - .configure(|cfg| images::config(cfg, client.clone(), &rate_limiter)) + .configure(|cfg| images::config(cfg, pictrs_client.clone(), &rate_limiter)) .configure(nodeinfo::config) .configure(|cfg| webfinger::config(cfg, &settings)) })