From c323ab527585e9bab0f9c9f2d1118790ea2e7ae6 Mon Sep 17 00:00:00 2001
From: Dessalines <dessalines@users.noreply.github.com>
Date: Mon, 17 Aug 2020 14:12:36 -0400
Subject: [PATCH] Added option to remove banned user data (posts, comments,
 communities) (#1093)

- Works for both a site-ban, and a community ban.
- Fixes #557
---
 docs/src/contributing_websocket_http_api.md |  2 +
 server/lemmy_db/src/comment.rs              | 18 +++++--
 server/lemmy_db/src/community.rs            | 11 ++++
 server/lemmy_db/src/post.rs                 | 26 +++++++--
 server/src/api/community.rs                 | 33 ++++++++++++
 server/src/api/user.rs                      | 59 ++++++++++-----------
 ui/src/components/comment-node.tsx          | 37 ++++++++++++-
 ui/src/components/post-listing.tsx          | 37 ++++++++++++-
 ui/src/interfaces.ts                        |  2 +
 ui/translations/en.json                     |  1 +
 10 files changed, 184 insertions(+), 42 deletions(-)

diff --git a/docs/src/contributing_websocket_http_api.md b/docs/src/contributing_websocket_http_api.md
index fa241d162..8ad7fbbf8 100644
--- a/docs/src/contributing_websocket_http_api.md
+++ b/docs/src/contributing_websocket_http_api.md
@@ -818,6 +818,7 @@ Marks all user replies and mentions as read.
   data: {
     user_id: i32,
     ban: bool,
+    remove_data: Option<bool>, // Removes/Restores their comments, posts, and communities
     reason: Option<String>,
     expires: Option<i64>,
     auth: String
@@ -1177,6 +1178,7 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
     community_id: i32,
     user_id: i32,
     ban: bool,
+    remove_data: Option<bool>, // Removes/Restores their comments and posts for that community
     reason: Option<String>,
     expires: Option<i64>,
     auth: String
diff --git a/server/lemmy_db/src/comment.rs b/server/lemmy_db/src/comment.rs
index 8e52d7e2d..f5a036f1a 100644
--- a/server/lemmy_db/src/comment.rs
+++ b/server/lemmy_db/src/comment.rs
@@ -97,16 +97,15 @@ impl Comment {
     comment.filter(ap_id.eq(object_id)).first::<Self>(conn)
   }
 
-  pub fn permadelete(conn: &PgConnection, comment_id: i32) -> Result<Self, Error> {
+  pub fn permadelete_for_creator(conn: &PgConnection, for_creator_id: i32) -> Result<Vec<Self>, Error> {
     use crate::schema::comment::dsl::*;
-
-    diesel::update(comment.find(comment_id))
+    diesel::update(comment.filter(creator_id.eq(for_creator_id)))
       .set((
         content.eq("*Permananently Deleted*"),
         deleted.eq(true),
         updated.eq(naive_now()),
       ))
-      .get_result::<Self>(conn)
+      .get_results::<Self>(conn)
   }
 
   pub fn update_deleted(
@@ -131,6 +130,17 @@ impl Comment {
       .get_result::<Self>(conn)
   }
 
+  pub fn update_removed_for_creator(
+    conn: &PgConnection,
+    for_creator_id: i32,
+    new_removed: bool,
+  ) -> Result<Vec<Self>, Error> {
+    use crate::schema::comment::dsl::*;
+    diesel::update(comment.filter(creator_id.eq(for_creator_id)))
+      .set((removed.eq(new_removed), updated.eq(naive_now())))
+      .get_results::<Self>(conn)
+  }
+
   pub fn update_read(conn: &PgConnection, comment_id: i32, new_read: bool) -> Result<Self, Error> {
     use crate::schema::comment::dsl::*;
     diesel::update(comment.find(comment_id))
diff --git a/server/lemmy_db/src/community.rs b/server/lemmy_db/src/community.rs
index 7490f3664..df5f12941 100644
--- a/server/lemmy_db/src/community.rs
+++ b/server/lemmy_db/src/community.rs
@@ -121,6 +121,17 @@ impl Community {
       .get_result::<Self>(conn)
   }
 
+  pub fn update_removed_for_creator(
+    conn: &PgConnection,
+    for_creator_id: i32,
+    new_removed: bool,
+  ) -> Result<Vec<Self>, Error> {
+    use crate::schema::community::dsl::*;
+    diesel::update(community.filter(creator_id.eq(for_creator_id)))
+      .set((removed.eq(new_removed), updated.eq(naive_now())))
+      .get_results::<Self>(conn)
+  }
+
   pub fn update_creator(
     conn: &PgConnection,
     community_id: i32,
diff --git a/server/lemmy_db/src/post.rs b/server/lemmy_db/src/post.rs
index 1185aa845..d73901bb0 100644
--- a/server/lemmy_db/src/post.rs
+++ b/server/lemmy_db/src/post.rs
@@ -95,13 +95,13 @@ impl Post {
       .get_result::<Self>(conn)
   }
 
-  pub fn permadelete(conn: &PgConnection, post_id: i32) -> Result<Self, Error> {
+  pub fn permadelete_for_creator(conn: &PgConnection, for_creator_id: i32) -> Result<Vec<Self>, Error> {
     use crate::schema::post::dsl::*;
 
     let perma_deleted = "*Permananently Deleted*";
     let perma_deleted_url = "https://deleted.com";
 
-    diesel::update(post.find(post_id))
+    diesel::update(post.filter(creator_id.eq(for_creator_id)))
       .set((
         name.eq(perma_deleted),
         url.eq(perma_deleted_url),
@@ -109,7 +109,7 @@ impl Post {
         deleted.eq(true),
         updated.eq(naive_now()),
       ))
-      .get_result::<Self>(conn)
+      .get_results::<Self>(conn)
   }
 
   pub fn update_deleted(
@@ -134,6 +134,26 @@ impl Post {
       .get_result::<Self>(conn)
   }
 
+  pub fn update_removed_for_creator(
+    conn: &PgConnection,
+    for_creator_id: i32,
+    for_community_id: Option<i32>,
+    new_removed: bool,
+  ) -> Result<Vec<Self>, Error> {
+    use crate::schema::post::dsl::*;
+
+    let mut update = diesel::update(post).into_boxed();
+    update = update.filter(creator_id.eq(for_creator_id));
+
+    if let Some(for_community_id) = for_community_id {
+      update = update.filter(community_id.eq(for_community_id));
+    }
+
+    update
+      .set((removed.eq(new_removed), updated.eq(naive_now())))
+      .get_results::<Self>(conn)
+  }
+
   pub fn update_locked(conn: &PgConnection, post_id: i32, new_locked: bool) -> Result<Self, Error> {
     use crate::schema::post::dsl::*;
     diesel::update(post.find(post_id))
diff --git a/server/src/api/community.rs b/server/src/api/community.rs
index 01c43ce11..bd6714563 100644
--- a/server/src/api/community.rs
+++ b/server/src/api/community.rs
@@ -13,8 +13,11 @@ use crate::{
 use actix_web::client::Client;
 use anyhow::Context;
 use lemmy_db::{
+  comment::Comment,
+  comment_view::CommentQueryBuilder,
   diesel_option_overwrite,
   naive_now,
+  post::Post,
   Bannable,
   Crud,
   Followable,
@@ -81,6 +84,7 @@ pub struct BanFromCommunity {
   pub community_id: i32,
   user_id: i32,
   ban: bool,
+  remove_data: Option<bool>,
   reason: Option<String>,
   expires: Option<i64>,
   auth: String,
@@ -676,6 +680,7 @@ impl Perform for BanFromCommunity {
     let user = get_user_from_jwt(&data.auth, pool).await?;
 
     let community_id = data.community_id;
+    let banned_user_id = data.user_id;
 
     // Verify that only mods or admins can ban
     is_mod_or_admin(pool, user.id, community_id).await?;
@@ -697,6 +702,34 @@ impl Perform for BanFromCommunity {
       }
     }
 
+    // Remove/Restore their data if that's desired
+    if let Some(remove_data) = data.remove_data {
+      // Posts
+      blocking(pool, move |conn: &'_ _| {
+        Post::update_removed_for_creator(conn, banned_user_id, Some(community_id), remove_data)
+      })
+      .await??;
+
+      // Comments
+      // Diesel doesn't allow updates with joins, so this has to be a loop
+      let comments = blocking(pool, move |conn| {
+        CommentQueryBuilder::create(conn)
+          .for_creator_id(banned_user_id)
+          .for_community_id(community_id)
+          .limit(std::i64::MAX)
+          .list()
+      })
+      .await??;
+
+      for comment in &comments {
+        let comment_id = comment.id;
+        blocking(pool, move |conn: &'_ _| {
+          Comment::update_removed(conn, comment_id, remove_data)
+        })
+        .await??;
+      }
+    }
+
     // Mod tables
     // TODO eventually do correct expires
     let expires = match data.expires {
diff --git a/server/src/api/user.rs b/server/src/api/user.rs
index d8c03b695..bbb8d482b 100644
--- a/server/src/api/user.rs
+++ b/server/src/api/user.rs
@@ -177,6 +177,7 @@ pub struct AddAdminResponse {
 pub struct BanUser {
   user_id: i32,
   ban: bool,
+  remove_data: Option<bool>,
   reason: Option<String>,
   expires: Option<i64>,
   auth: String,
@@ -850,6 +851,27 @@ impl Perform for BanUser {
       return Err(APIError::err("couldnt_update_user").into());
     }
 
+    // Remove their data if that's desired
+    if let Some(remove_data) = data.remove_data {
+      // Posts
+      blocking(pool, move |conn: &'_ _| {
+        Post::update_removed_for_creator(conn, banned_user_id, None, remove_data)
+      })
+      .await??;
+
+      // Communities
+      blocking(pool, move |conn: &'_ _| {
+        Community::update_removed_for_creator(conn, banned_user_id, remove_data)
+      })
+      .await??;
+
+      // Comments
+      blocking(pool, move |conn: &'_ _| {
+        Comment::update_removed_for_creator(conn, banned_user_id, remove_data)
+      })
+      .await??;
+    }
+
     // Mod tables
     let expires = match data.expires {
       Some(time) => Some(naive_from_unix(time)),
@@ -1064,40 +1086,15 @@ impl Perform for DeleteAccount {
 
     // Comments
     let user_id = user.id;
-    let comments = blocking(pool, move |conn| {
-      CommentQueryBuilder::create(conn)
-        .for_creator_id(user_id)
-        .limit(std::i64::MAX)
-        .list()
-    })
-    .await??;
-
-    // TODO: this should probably be a bulk operation
-    for comment in &comments {
-      let comment_id = comment.id;
-      let permadelete = move |conn: &'_ _| Comment::permadelete(conn, comment_id);
-      if blocking(pool, permadelete).await?.is_err() {
-        return Err(APIError::err("couldnt_update_comment").into());
-      }
+    let permadelete = move |conn: &'_ _| Comment::permadelete_for_creator(conn, user_id);
+    if blocking(pool, permadelete).await?.is_err() {
+      return Err(APIError::err("couldnt_update_comment").into());
     }
 
     // Posts
-    let posts = blocking(pool, move |conn| {
-      PostQueryBuilder::create(conn)
-        .sort(&SortType::New)
-        .for_creator_id(user_id)
-        .limit(std::i64::MAX)
-        .list()
-    })
-    .await??;
-
-    // TODO: this should probably be a bulk operation
-    for post in &posts {
-      let post_id = post.id;
-      let permadelete = move |conn: &'_ _| Post::permadelete(conn, post_id);
-      if blocking(pool, permadelete).await?.is_err() {
-        return Err(APIError::err("couldnt_update_post").into());
-      }
+    let permadelete = move |conn: &'_ _| Post::permadelete_for_creator(conn, user_id);
+    if blocking(pool, permadelete).await?.is_err() {
+      return Err(APIError::err("couldnt_update_post").into());
     }
 
     Ok(LoginResponse {
diff --git a/ui/src/components/comment-node.tsx b/ui/src/components/comment-node.tsx
index ef8a07182..13263b822 100644
--- a/ui/src/components/comment-node.tsx
+++ b/ui/src/components/comment-node.tsx
@@ -43,6 +43,7 @@ interface CommentNodeState {
   showRemoveDialog: boolean;
   removeReason: string;
   showBanDialog: boolean;
+  removeData: boolean;
   banReason: string;
   banExpires: string;
   banType: BanType;
@@ -87,6 +88,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     showRemoveDialog: false,
     removeReason: null,
     showBanDialog: false,
+    removeData: null,
     banReason: null,
     banExpires: null,
     banType: BanType.Community,
@@ -699,6 +701,20 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                 value={this.state.banReason}
                 onInput={linkEvent(this, this.handleModBanReasonChange)}
               />
+              <div class="form-group">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="mod-ban-remove-data"
+                    type="checkbox"
+                    checked={this.state.removeData}
+                    onChange={linkEvent(this, this.handleModRemoveDataChange)}
+                  />
+                  <label class="form-check-label" htmlFor="mod-ban-remove-data">
+                    {i18n.t('remove_posts_comments')}
+                  </label>
+                </div>
+              </div>
             </div>
             {/* TODO hold off on expires until later */}
             {/* <div class="form-group row"> */}
@@ -951,6 +967,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState(i.state);
   }
 
+  handleModRemoveDataChange(i: CommentNode, event: any) {
+    i.state.removeData = event.target.checked;
+    i.setState(i.state);
+  }
+
   handleModRemoveSubmit(i: CommentNode) {
     event.preventDefault();
     let form: RemoveCommentForm = {
@@ -1024,18 +1045,30 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     event.preventDefault();
 
     if (i.state.banType == BanType.Community) {
+      // If its an unban, restore all their data
+      let ban = !i.props.node.comment.banned_from_community;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
       let form: BanFromCommunityForm = {
         user_id: i.props.node.comment.creator_id,
         community_id: i.props.node.comment.community_id,
-        ban: !i.props.node.comment.banned_from_community,
+        ban,
+        remove_data: i.state.removeData,
         reason: i.state.banReason,
         expires: getUnixTime(i.state.banExpires),
       };
       WebSocketService.Instance.banFromCommunity(form);
     } else {
+      // If its an unban, restore all their data
+      let ban = !i.props.node.comment.banned;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
       let form: BanUserForm = {
         user_id: i.props.node.comment.creator_id,
-        ban: !i.props.node.comment.banned,
+        ban,
+        remove_data: i.state.removeData,
         reason: i.state.banReason,
         expires: getUnixTime(i.state.banExpires),
       };
diff --git a/ui/src/components/post-listing.tsx b/ui/src/components/post-listing.tsx
index 49ec30343..e3e19e99c 100644
--- a/ui/src/components/post-listing.tsx
+++ b/ui/src/components/post-listing.tsx
@@ -44,6 +44,7 @@ interface PostListingState {
   showRemoveDialog: boolean;
   removeReason: string;
   showBanDialog: boolean;
+  removeData: boolean;
   banReason: string;
   banExpires: string;
   banType: BanType;
@@ -74,6 +75,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     showRemoveDialog: false,
     removeReason: null,
     showBanDialog: false,
+    removeData: null,
     banReason: null,
     banExpires: null,
     banType: BanType.Community,
@@ -931,6 +933,20 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 value={this.state.banReason}
                 onInput={linkEvent(this, this.handleModBanReasonChange)}
               />
+              <div class="form-group">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="mod-ban-remove-data"
+                    type="checkbox"
+                    checked={this.state.removeData}
+                    onChange={linkEvent(this, this.handleModRemoveDataChange)}
+                  />
+                  <label class="form-check-label" htmlFor="mod-ban-remove-data">
+                    {i18n.t('remove_posts_comments')}
+                  </label>
+                </div>
+              </div>
             </div>
             {/* TODO hold off on expires until later */}
             {/* <div class="form-group row"> */}
@@ -1241,6 +1257,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     i.setState(i.state);
   }
 
+  handleModRemoveDataChange(i: PostListing, event: any) {
+    i.state.removeData = event.target.checked;
+    i.setState(i.state);
+  }
+
   handleModRemoveSubmit(i: PostListing) {
     event.preventDefault();
     let form: RemovePostForm = {
@@ -1311,18 +1332,30 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     event.preventDefault();
 
     if (i.state.banType == BanType.Community) {
+      // If its an unban, restore all their data
+      let ban = !i.props.post.banned_from_community;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
       let form: BanFromCommunityForm = {
         user_id: i.props.post.creator_id,
         community_id: i.props.post.community_id,
-        ban: !i.props.post.banned_from_community,
+        ban,
+        remove_data: i.state.removeData,
         reason: i.state.banReason,
         expires: getUnixTime(i.state.banExpires),
       };
       WebSocketService.Instance.banFromCommunity(form);
     } else {
+      // If its an unban, restore all their data
+      let ban = !i.props.post.banned;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
       let form: BanUserForm = {
         user_id: i.props.post.creator_id,
-        ban: !i.props.post.banned,
+        ban,
+        remove_data: i.state.removeData,
         reason: i.state.banReason,
         expires: getUnixTime(i.state.banExpires),
       };
diff --git a/ui/src/interfaces.ts b/ui/src/interfaces.ts
index b449060df..b2995926c 100644
--- a/ui/src/interfaces.ts
+++ b/ui/src/interfaces.ts
@@ -413,6 +413,7 @@ export interface BanFromCommunityForm {
   community_id: number;
   user_id: number;
   ban: boolean;
+  remove_data?: boolean;
   reason?: string;
   expires?: number;
   auth?: string;
@@ -877,6 +878,7 @@ export interface SiteResponse {
 export interface BanUserForm {
   user_id: number;
   ban: boolean;
+  remove_data?: boolean;
   reason?: string;
   expires?: number;
   auth?: string;
diff --git a/ui/translations/en.json b/ui/translations/en.json
index cc73c326a..de1fc8268 100644
--- a/ui/translations/en.json
+++ b/ui/translations/en.json
@@ -15,6 +15,7 @@
     "number_of_comments": "{{count}} Comment",
     "number_of_comments_plural": "{{count}} Comments",
     "remove_comment": "Remove Comment",
+    "remove_posts_comments": "Remove Posts and Comments",
     "communities": "Communities",
     "users": "Users",
     "create_a_community": "Create a community",