]> Untitled Git - lemmy.git/commitdiff
Adding admin purging of DB items and pictures. #904 #1331 (#1809)
authorDessalines <dessalines@users.noreply.github.com>
Mon, 13 Jun 2022 19:15:04 +0000 (15:15 -0400)
committerGitHub <noreply@github.com>
Mon, 13 Jun 2022 19:15:04 +0000 (19:15 +0000)
* 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 <me@nutomic.com>
38 files changed:
config/defaults.hjson
crates/api/src/lib.rs
crates/api/src/local_user/ban_person.rs
crates/api/src/site/mod.rs
crates/api/src/site/mod_log.rs
crates/api/src/site/purge/comment.rs [new file with mode: 0644]
crates/api/src/site/purge/community.rs [new file with mode: 0644]
crates/api/src/site/purge/mod.rs [new file with mode: 0644]
crates/api/src/site/purge/person.rs [new file with mode: 0644]
crates/api/src/site/purge/post.rs [new file with mode: 0644]
crates/api_common/src/request.rs
crates/api_common/src/site.rs
crates/api_common/src/utils.rs
crates/api_crud/src/user/delete.rs
crates/apub/src/activities/block/block_user.rs
crates/apub/src/activities/deletion/delete_user.rs
crates/db_schema/src/impls/community.rs
crates/db_schema/src/impls/moderator.rs
crates/db_schema/src/impls/person.rs
crates/db_schema/src/impls/post.rs
crates/db_schema/src/schema.rs
crates/db_schema/src/source/moderator.rs
crates/db_views_moderator/src/admin_purge_comment_view.rs [new file with mode: 0644]
crates/db_views_moderator/src/admin_purge_community_view.rs [new file with mode: 0644]
crates/db_views_moderator/src/admin_purge_person_view.rs [new file with mode: 0644]
crates/db_views_moderator/src/admin_purge_post_view.rs [new file with mode: 0644]
crates/db_views_moderator/src/lib.rs
crates/db_views_moderator/src/structs.rs
crates/routes/src/images.rs
crates/utils/src/settings/mod.rs
crates/utils/src/settings/structs.rs
crates/websocket/src/lib.rs
docker/dev/docker-compose.yml
docker/lemmy.hjson
migrations/2021-10-01-141650_create_admin_purge/down.sql [new file with mode: 0644]
migrations/2021-10-01-141650_create_admin_purge/up.sql [new file with mode: 0644]
src/api_routes.rs
src/main.rs

index 1ec60243a007bdd01ac16798cc7f132ad824013d..acd956a8e3b5228bd07b379c20c68b2e60530220 100644 (file)
     # 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
   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
index 6e2bb4f0e6851588d9ac24203fa50654c2651e14..5083b2867afd67bee14284ef42ed3bcb1a5a3b74 100644 (file)
@@ -104,6 +104,16 @@ pub async fn match_websocket_operation(
     UserOperation::SaveSiteConfig => {
       do_websocket_operation::<SaveSiteConfig>(context, id, op, data).await
     }
+    UserOperation::PurgePerson => {
+      do_websocket_operation::<PurgePerson>(context, id, op, data).await
+    }
+    UserOperation::PurgeCommunity => {
+      do_websocket_operation::<PurgeCommunity>(context, id, op, data).await
+    }
+    UserOperation::PurgePost => do_websocket_operation::<PurgePost>(context, id, op, data).await,
+    UserOperation::PurgeComment => {
+      do_websocket_operation::<PurgeComment>(context, id, op, data).await
+    }
     UserOperation::Search => do_websocket_operation::<Search>(context, id, op, data).await,
     UserOperation::ResolveObject => {
       do_websocket_operation::<ResolveObject>(context, id, op, data).await
index 6de826dca1ec06c3cac38bd08dcb48ae57c2f0ed..e9900985db8b7a196b72d5e3b92f3e76dae72dc0 100644 (file)
@@ -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
index b8b9dd78563d2f5bce3672c911cc79be63b4bd72..1c860cb4a7bfbd6caff5b756f816d6b0cecd8f69 100644 (file)
@@ -1,6 +1,7 @@
 mod config;
 mod leave_admin;
 mod mod_log;
+mod purge;
 mod registration_applications;
 mod resolve_object;
 mod search;
index acb5e827e7dd46aadd83085d3b9b67a12817aee1..a96595da9c38a23c1def8dcb699c4f3483be321f 100644 (file)
@@ -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 (file)
index 0000000..e850931
--- /dev/null
@@ -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<LemmyContext>,
+    _websocket_id: Option<ConnectionId>,
+  ) -> Result<Self::Response, LemmyError> {
+    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 (file)
index 0000000..9acb2a8
--- /dev/null
@@ -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<LemmyContext>,
+    _websocket_id: Option<ConnectionId>,
+  ) -> Result<Self::Response, LemmyError> {
+    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 (file)
index 0000000..aa00ba6
--- /dev/null
@@ -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 (file)
index 0000000..fe87585
--- /dev/null
@@ -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<LemmyContext>,
+    _websocket_id: Option<ConnectionId>,
+  ) -> Result<Self::Response, LemmyError> {
+    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 (file)
index 0000000..9908008
--- /dev/null
@@ -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<LemmyContext>,
+    _websocket_id: Option<ConnectionId>,
+  ) -> Result<Self::Response, LemmyError> {
+    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 })
+  }
+}
index 8578996093ed5f512ca254d0c260b78938a93fe1..37bb39a0c0c41815c9029bbc548b2a5939821440 100644 (file)
@@ -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<PictrsResponse, LemmyError> {
-  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))
   }
 }
 
index 71b3d0def1b1690404e5645900ff8ed8fa0d7da3..f61d34055570584a941844ae7f77d82805116a2d 100644 (file)
@@ -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<ModAddCommunityView>,
   pub transferred_to_community: Vec<ModTransferCommunityView>,
   pub added: Vec<ModAddView>,
+  pub admin_purged_persons: Vec<AdminPurgePersonView>,
+  pub admin_purged_communities: Vec<AdminPurgeCommunityView>,
+  pub admin_purged_posts: Vec<AdminPurgePostView>,
+  pub admin_purged_comments: Vec<AdminPurgeCommentView>,
   pub hidden_communities: Vec<ModHideCommunityView>,
 }
 
@@ -194,6 +202,39 @@ pub struct FederatedInstances {
   pub blocked: Option<Vec<String>>,
 }
 
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct PurgePerson {
+  pub person_id: PersonId,
+  pub reason: Option<String>,
+  pub auth: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct PurgeCommunity {
+  pub community_id: CommunityId,
+  pub reason: Option<String>,
+  pub auth: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct PurgePost {
+  pub post_id: PostId,
+  pub reason: Option<String>,
+  pub auth: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct PurgeComment {
+  pub comment_id: CommentId,
+  pub reason: Option<String>,
+  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)
index f8a25251f95e6dcefb9cbcc506e940ac26e09f7d..e96325cfd5a2055d22302a8928030b0849771cf8 100644 (file)
@@ -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(())
index bde403a5610dcc015b71753dfe1a2d7ec159667c..7273d990a3708e3812e9e635f67b114e483fb845 100644 (file)
@@ -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 {})
index 2166259ce3f8ac63febca50a8d61455fe86a34d7..32feea60417cfc12accf2887d2a77fc056576959 100644 (file)
@@ -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
index 771d33eb35d1f74b1e6680975c4ffdd47958f6b9..0e342bd17de174e594ff61c639ff56fbbac052d6 100644 (file)
@@ -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(())
   }
 }
index 14a670f51c58ad1c7e33263b582fd8a678f879cc..dc539c693fe56df72749f6a14cb7142870745e02 100644 (file)
@@ -138,6 +138,19 @@ impl Community {
       .set(community_form)
       .get_result::<Self>(conn)
   }
+
+  pub fn remove_avatar_and_banner(
+    conn: &PgConnection,
+    community_id: CommunityId,
+  ) -> Result<Self, Error> {
+    use crate::schema::community::dsl::*;
+    diesel::update(community.find(community_id))
+      .set((
+        icon.eq::<Option<String>>(None),
+        banner.eq::<Option<String>>(None),
+      ))
+      .get_result::<Self>(conn)
+  }
 }
 
 impl Joinable for CommunityModerator {
index 36582d109bdcf5caf8ca7079112ea092f2350264..d432373594631c94a72e2e9429645f2630a66a2d 100644 (file)
@@ -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<Self, Error> {
+    use crate::schema::admin_purge_person::dsl::*;
+    admin_purge_person.find(from_id).first::<Self>(conn)
+  }
+
+  fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_person::dsl::*;
+    insert_into(admin_purge_person)
+      .values(form)
+      .get_result::<Self>(conn)
+  }
+
+  fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_person::dsl::*;
+    diesel::update(admin_purge_person.find(from_id))
+      .set(form)
+      .get_result::<Self>(conn)
+  }
+}
+
+impl Crud for AdminPurgeCommunity {
+  type Form = AdminPurgeCommunityForm;
+  type IdType = i32;
+  fn read(conn: &PgConnection, from_id: i32) -> Result<Self, Error> {
+    use crate::schema::admin_purge_community::dsl::*;
+    admin_purge_community.find(from_id).first::<Self>(conn)
+  }
+
+  fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_community::dsl::*;
+    insert_into(admin_purge_community)
+      .values(form)
+      .get_result::<Self>(conn)
+  }
+
+  fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_community::dsl::*;
+    diesel::update(admin_purge_community.find(from_id))
+      .set(form)
+      .get_result::<Self>(conn)
+  }
+}
+
+impl Crud for AdminPurgePost {
+  type Form = AdminPurgePostForm;
+  type IdType = i32;
+  fn read(conn: &PgConnection, from_id: i32) -> Result<Self, Error> {
+    use crate::schema::admin_purge_post::dsl::*;
+    admin_purge_post.find(from_id).first::<Self>(conn)
+  }
+
+  fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_post::dsl::*;
+    insert_into(admin_purge_post)
+      .values(form)
+      .get_result::<Self>(conn)
+  }
+
+  fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_post::dsl::*;
+    diesel::update(admin_purge_post.find(from_id))
+      .set(form)
+      .get_result::<Self>(conn)
+  }
+}
+
+impl Crud for AdminPurgeComment {
+  type Form = AdminPurgeCommentForm;
+  type IdType = i32;
+  fn read(conn: &PgConnection, from_id: i32) -> Result<Self, Error> {
+    use crate::schema::admin_purge_comment::dsl::*;
+    admin_purge_comment.find(from_id).first::<Self>(conn)
+  }
+
+  fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_comment::dsl::*;
+    insert_into(admin_purge_comment)
+      .values(form)
+      .get_result::<Self>(conn)
+  }
+
+  fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
+    use crate::schema::admin_purge_comment::dsl::*;
+    diesel::update(admin_purge_comment.find(from_id))
+      .set(form)
+      .get_result::<Self>(conn)
+  }
+}
+
 #[cfg(test)]
 mod tests {
   use crate::{
index c59126c32298a2205528e0fee06cc67da84389fd..1389544e180e72748ae7c3c08e820052b04029af 100644 (file)
@@ -228,6 +228,8 @@ impl Person {
     diesel::update(person.find(person_id))
       .set((
         display_name.eq::<Option<String>>(None),
+        avatar.eq::<Option<String>>(None),
+        banner.eq::<Option<String>>(None),
         bio.eq::<Option<String>>(None),
         matrix_user_id.eq::<Option<String>>(None),
         deleted.eq(true),
@@ -265,6 +267,15 @@ impl Person {
       .set(admin.eq(false))
       .get_result::<Self>(conn)
   }
+
+  pub fn remove_avatar_and_banner(conn: &PgConnection, person_id: PersonId) -> Result<Self, Error> {
+    diesel::update(person.find(person_id))
+      .set((
+        avatar.eq::<Option<String>>(None),
+        banner.eq::<Option<String>>(None),
+      ))
+      .get_result::<Self>(conn)
+  }
 }
 
 impl PersonSafe {
index 03a4b7192ba939fe351725d5245992c84ee669f9..9996734169ef5da41017954b2ce4a605854e4bd2 100644 (file)
@@ -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<Vec<Self>, 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::<Self>(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<Vec<Self>, 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::<Option<String>>(None),
+      thumbnail_url.eq::<Option<String>>(None),
+    ))
+    .get_results::<Self>(conn)
+  }
+
+  pub fn fetch_pictrs_posts_for_community(
+    conn: &PgConnection,
+    for_community_id: CommunityId,
+  ) -> Result<Vec<Self>, 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::<Self>(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<Vec<Self>, 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::<Option<String>>(None),
+      thumbnail_url.eq::<Option<String>>(None),
+    ))
+    .get_results::<Self>(conn)
+  }
 }
 
 impl Likeable for PostLike {
index 6146b9e3056d764d4b3f53886c08b45e41359e26..91ad72789d9670c17388d6c6d4d2d29cb725e254 100644 (file)
@@ -577,6 +577,16 @@ table! {
   }
 }
 
+table! {
+  admin_purge_comment (id) {
+    id -> Int4,
+    admin_person_id -> Int4,
+    post_id -> Int4,
+    reason -> Nullable<Text>,
+    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<Text>,
+    when_ -> Timestamp,
+  }
+}
+
+table! {
+  admin_purge_person (id) {
+    id -> Int4,
+    admin_person_id -> Int4,
+    reason -> Nullable<Text>,
+    when_ -> Timestamp,
+  }
+}
+
+table! {
+  admin_purge_post (id) {
+    id -> Int4,
+    admin_person_id -> Int4,
+    community_id -> Int4,
+    reason -> Nullable<Text>,
+    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
 );
index b617564b357bf02bda9e2203518bc767fc364d03..7f13eaa9cce8fdcb7fb3fa13d7784a2b63e121ef 100644 (file)
@@ -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<bool>,
 }
+
+#[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<String>,
+  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<String>,
+}
+
+#[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<String>,
+  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<String>,
+}
+
+#[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<String>,
+  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<String>,
+}
+
+#[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<String>,
+  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<String>,
+}
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 (file)
index 0000000..b8c8f74
--- /dev/null
@@ -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<PersonId>,
+    page: Option<i64>,
+    limit: Option<i64>,
+  ) -> Result<Vec<Self>, 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::<AdminPurgeCommentViewTuple>(conn)?;
+
+    Ok(Self::from_tuple_to_vec(res))
+  }
+}
+
+impl ViewToVec for AdminPurgeCommentView {
+  type DbTuple = AdminPurgeCommentViewTuple;
+  fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
+    items
+      .iter()
+      .map(|a| Self {
+        admin_purge_comment: a.0.to_owned(),
+        admin: a.1.to_owned(),
+        post: a.2.to_owned(),
+      })
+      .collect::<Vec<Self>>()
+  }
+}
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 (file)
index 0000000..0c40a39
--- /dev/null
@@ -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<PersonId>,
+    page: Option<i64>,
+    limit: Option<i64>,
+  ) -> Result<Vec<Self>, 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::<AdminPurgeCommunityViewTuple>(conn)?;
+
+    Ok(Self::from_tuple_to_vec(res))
+  }
+}
+
+impl ViewToVec for AdminPurgeCommunityView {
+  type DbTuple = AdminPurgeCommunityViewTuple;
+  fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
+    items
+      .iter()
+      .map(|a| Self {
+        admin_purge_community: a.0.to_owned(),
+        admin: a.1.to_owned(),
+      })
+      .collect::<Vec<Self>>()
+  }
+}
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 (file)
index 0000000..9828e43
--- /dev/null
@@ -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<PersonId>,
+    page: Option<i64>,
+    limit: Option<i64>,
+  ) -> Result<Vec<Self>, 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::<AdminPurgePersonViewTuple>(conn)?;
+
+    Ok(Self::from_tuple_to_vec(res))
+  }
+}
+
+impl ViewToVec for AdminPurgePersonView {
+  type DbTuple = AdminPurgePersonViewTuple;
+  fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
+    items
+      .iter()
+      .map(|a| Self {
+        admin_purge_person: a.0.to_owned(),
+        admin: a.1.to_owned(),
+      })
+      .collect::<Vec<Self>>()
+  }
+}
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 (file)
index 0000000..6665e46
--- /dev/null
@@ -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<PersonId>,
+    page: Option<i64>,
+    limit: Option<i64>,
+  ) -> Result<Vec<Self>, 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::<AdminPurgePostViewTuple>(conn)?;
+
+    Ok(Self::from_tuple_to_vec(res))
+  }
+}
+
+impl ViewToVec for AdminPurgePostView {
+  type DbTuple = AdminPurgePostViewTuple;
+  fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
+    items
+      .iter()
+      .map(|a| Self {
+        admin_purge_post: a.0.to_owned(),
+        admin: a.1.to_owned(),
+        community: a.2.to_owned(),
+      })
+      .collect::<Vec<Self>>()
+  }
+}
index c6f32426b72cc353e55b2fcb611f48891f975823..60d5c7877ef9a4943297768e9f686c5461fb9a24 100644 (file)
@@ -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;
index 9d525b57b11e9d5440797f032e8d0cd6852c6dbd..ebe93399c0063aba7cdb9c61d79ab15ba220a931 100644 (file)
@@ -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,
+}
index 23d2020b7023412cc8786da9fcfd3c07f0e43f64..edd8fc8d468c1a21d6bdde9a18f69fa103a50950 100644 (file)
@@ -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<String>,
 }
 
+#[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<HttpResponse, Error> {
   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<String>) -> Result<String, LemmyError> {
-  pictrs_url.ok_or_else(|| anyhow!("images_disabled").into())
+async fn purge(
+  web::Query(params): web::Query<PictrsPurgeParams>,
+  req: HttpRequest,
+  client: web::Data<ClientWithMiddleware>,
+  context: web::Data<LemmyContext>,
+) -> Result<HttpResponse, Error> {
+  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<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static
index 50597f24449e889413bc399503a7c982065c5075..513f7dedaab7828f2d89a9e50b658f436b92b677 100644 (file)
@@ -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<PictrsConfig, LemmyError> {
+    self
+      .pictrs_config
+      .to_owned()
+      .ok_or_else(|| anyhow!("images_disabled").into())
+  }
 }
index b703e0322c7c09ec83b0ccc7a573441fda476919..d6846f18551b8ef3d8688d55c5c1965ef881f035 100644 (file)
@@ -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<PictrsConfig>,
   #[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<String>,
   #[default(None)]
   #[doku(example = "(\\bThis\\b)|(\\bis\\b)|(\\bsample\\b)")]
+  /// A regex list of slurs to block / hide
   pub slur_filter: Option<String>,
   /// Maximum length of local community and user names
   #[default(20)]
@@ -56,6 +56,18 @@ pub struct Settings {
   pub opentelemetry_url: Option<String>,
 }
 
+#[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 {
index 2a7d1e8f49a554d1f45bb253807c7de3ea4da549..bac223b627a440561bb4d867f884a26e22163ce5 100644 (file)
@@ -142,6 +142,10 @@ pub enum UserOperation {
   GetSiteMetadata,
   BlockCommunity,
   BlockPerson,
+  PurgePerson,
+  PurgeCommunity,
+  PurgePost,
+  PurgeComment,
 }
 
 #[derive(EnumString, Display, Debug, Clone)]
index b3f1201e2b378b2e1d563d19197aac6d58f4e511..737e926e3466059516bc77eaf47a32d34cf2a49c 100644 (file)
@@ -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
index edf37568d53961ac7ddf021aad402da689a304bb..585f1446893c0437716b13eb20cb68f4be369baa 100644 (file)
   # 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 (file)
index 0000000..054923f
--- /dev/null
@@ -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 (file)
index 0000000..1eb6b3f
--- /dev/null
@@ -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()
+);
index 36310386018cdf914ca11811ce37d7fda37036fd..c9ff8803c7c7a19aa40e390846b5c217ec901d4f 100644 (file)
@@ -232,6 +232,14 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
             "/registration_application/approve",
             web::put().to(route_post::<ApproveRegistrationApplication>),
           ),
+      )
+      .service(
+        web::scope("/admin/purge")
+          .wrap(rate_limit.message())
+          .route("/person", web::post().to(route_post::<PurgePerson>))
+          .route("/community", web::post().to(route_post::<PurgeCommunity>))
+          .route("/post", web::post().to(route_post::<PurgePost>))
+          .route("/comment", web::post().to(route_post::<PurgeComment>)),
       ),
   );
 }
index 7f5946f5d3e788ebd8ebeae72f0bedf1664dbbcb..3fbd0c6564d5f2adda40fb1bf16d108b05121f8e 100644 (file)
@@ -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))
   })