From 67aca822e6d3b9040f698b48580e8de9bc897f9c Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 8 Jun 2020 08:43:25 -0400
Subject: [PATCH] feat: account content deletion, closes #8381

---
 public/language/en-GB/admin/manage/users.json | 11 +++++---
 public/language/en-GB/user.json               |  3 +++
 public/src/admin/manage/users.js              | 19 ++++++++++++++
 public/src/client/account/header.js           | 26 +++++++++++++++++++
 public/src/client/flags/detail.js             |  4 +++
 src/socket.io/admin/user.js                   | 14 ++++++++++
 src/user/delete.js                            | 11 +++++---
 src/views/admin/manage/users.tpl              |  1 +
 8 files changed, 81 insertions(+), 8 deletions(-)

diff --git a/public/language/en-GB/admin/manage/users.json b/public/language/en-GB/admin/manage/users.json
index ae225d5b6f..93add0d7a4 100644
--- a/public/language/en-GB/admin/manage/users.json
+++ b/public/language/en-GB/admin/manage/users.json
@@ -12,8 +12,9 @@
 	"unban": "Unban User(s)",
 	"reset-lockout": "Reset Lockout",
 	"reset-flags": "Reset Flags",
-	"delete": "Delete User(s)",
-	"purge": "Delete User(s) and Content",
+	"delete": "Delete <strong>User(s)</strong>",
+	"delete-content": "Delete User(s) <strong>Content</strong>",
+	"purge": "Delete <strong>User(s)</strong> and <strong>Content</strong>",
 	"download-csv": "Download CSV",
 	"manage-groups": "Manage Groups",
 	"add-group": "Add Group",
@@ -93,9 +94,11 @@
 	"alerts.validate-email-success": "Emails validated",
 	"alerts.validate-force-password-reset-success": "User(s) passwords have been reset and their existing sessions have been revoked.",
 	"alerts.password-reset-confirm": "Do you want to send password reset email(s) to these user(s)?",
-	"alerts.confirm-delete": "<b>Warning!</b><br/>Do you really want to delete user(s)?<br/> This action is not reversable! Only the user account will be deleted, their posts and topics will remain.",
+	"alerts.confirm-delete": "<strong>Warning!</strong><p>Do you really want to delete <strong>user(s)</strong>?</p><p>This action is not reversible! Only the user account will be deleted, their posts and topics will remain.</p>",
 	"alerts.delete-success": "User(s) Deleted!",
-	"alerts.confirm-purge": "<b>Warning!</b><br/>Do you really want to delete user(s) and their content?<br/> This action is not reversable! All user data and content will be erased!",
+	"alerts.confirm-delete-content": "<strong>Warning!</strong><p>Do you really want to delete these user(s) <strong>content</strong>?</p><p>This action is not reversible! The users' accounts will remain, but their posts and topics will be deleted.</p>",
+	"alerts.delete-content-success": "User(s) Content Deleted!",
+	"alerts.confirm-purge": "<strong>Warning!</strong><p>Do you really want to delete <strong>user(s) and their content</strong>?</p><p>This action is not reversible! All user data and content will be erased!</p>",
 	"alerts.create": "Create User",
 	"alerts.button-create": "Create",
 	"alerts.button-cancel": "Cancel",
diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json
index 54d0fcf272..aa43c69e4c 100644
--- a/public/language/en-GB/user.json
+++ b/public/language/en-GB/user.json
@@ -13,9 +13,12 @@
 	"ban_account_confirm": "Do you really want to ban this user?",
 	"unban_account": "Unban Account",
 	"delete_account": "Delete Account",
+	"delete_content": "Delete Account Content Only",
 	"delete_account_confirm": "Are you sure you want to delete your account? <br /><strong>This action is irreversible and you will not be able to recover any of your data</strong><br /><br />Enter your password to confirm that you wish to destroy this account.",
 	"delete_this_account_confirm": "Are you sure you want to delete this account? <br /><strong>This action is irreversible and you will not be able to recover any data</strong><br /><br />",
+	"delete_account_content_confirm": "Are you sure you want to delete this account's content (posts/topics/uploads)? <br /><strong>This action is irreversible and you will not be able to recover any data</strong><br /><br />",
 	"account-deleted": "Account deleted",
+	"account-content-deleted": "Account content deleted",
 
 	"fullname": "Full Name",
 	"website": "Website",
diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js
index ca4e63dc41..033613ee88 100644
--- a/public/src/admin/manage/users.js
+++ b/public/src/admin/manage/users.js
@@ -262,6 +262,25 @@ define('admin/manage/users', ['translator', 'benchpress', 'autocomplete'], funct
 			});
 		});
 
+		$('.delete-user-content').on('click', function () {
+			var uids = getSelectedUids();
+			if (!uids.length) {
+				return;
+			}
+
+			bootbox.confirm('[[admin/manage/users:alerts.confirm-delete-content]]', function (confirm) {
+				if (confirm) {
+					socket.emit('admin.user.deleteUsersContent', uids, function (err) {
+						if (err) {
+							return app.alertError(err.message);
+						}
+
+						app.alertSuccess('[[admin/manage/users:alerts.delete-content-success]]');
+					});
+				}
+			});
+		});
+
 		$('.delete-user-and-content').on('click', function () {
 			var uids = getSelectedUids();
 			if (!uids.length) {
diff --git a/public/src/client/account/header.js b/public/src/client/account/header.js
index 7ac50ef653..a8620ed98e 100644
--- a/public/src/client/account/header.js
+++ b/public/src/client/account/header.js
@@ -59,6 +59,7 @@ define('forum/account/header', [
 	// TODO: These exported methods are used in forum/flags/detail -- refactor??
 	AccountHeader.banAccount = banAccount;
 	AccountHeader.deleteAccount = deleteAccount;
+	AccountHeader.deleteContent = deleteContent;
 
 	function hidePrivateLinks() {
 		if (!app.user.uid || app.user.uid !== parseInt(ajaxify.data.theirid, 10)) {
@@ -201,6 +202,31 @@ define('forum/account/header', [
 		});
 	}
 
+	function deleteContent(theirid, onSuccess) {
+		theirid = theirid || ajaxify.data.theirid;
+
+		translator.translate('[[user:delete_account_content_confirm]]', function (translated) {
+			bootbox.confirm(translated, function (confirm) {
+				if (!confirm) {
+					return;
+				}
+
+				socket.emit('admin.user.deleteUsersContent', [theirid], function (err) {
+					if (err) {
+						return app.alertError(err.message);
+					}
+					app.alertSuccess('[[user:account-content-deleted]]');
+
+					if (typeof onSuccess === 'function') {
+						return onSuccess();
+					}
+
+					history.back();
+				});
+			});
+		});
+	}
+
 	function flagAccount() {
 		require(['flags'], function (flags) {
 			flags.showFlagModal({
diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js
index 353a09b66b..1f8ecbb1e2 100644
--- a/public/src/client/flags/detail.js
+++ b/public/src/client/flags/detail.js
@@ -52,6 +52,10 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b
 					AccountHeader.deleteAccount(uid, ajaxify.refresh);
 					break;
 
+				case 'delete-content':
+					AccountHeader.deleteContent(uid, ajaxify.refresh);
+					break;
+
 				case 'delete-post':
 					postAction('delete', ajaxify.data.target.pid, ajaxify.data.target.tid);
 					break;
diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js
index f0192cf6f9..05a7e8cc27 100644
--- a/src/socket.io/admin/user.js
+++ b/src/socket.io/admin/user.js
@@ -126,6 +126,20 @@ User.deleteUsers = async function (socket, uids) {
 	});
 };
 
+User.deleteUsersContent = async function (socket, uids) {
+	if (!Array.isArray(uids)) {
+		throw new Error('[[error:invalid-data]]');
+	}
+	const isMembers = await groups.isMembers(uids, 'administrators');
+	if (isMembers.includes(true)) {
+		throw new Error('[[error:cant-delete-other-admins]]');
+	}
+
+	await Promise.all(uids.map(async (uid) => {
+		await user.deleteContent(socket.uid, uid);
+	}));
+};
+
 User.deleteUsersAndContent = async function (socket, uids) {
 	deleteUsers(socket, uids, async function (uid) {
 		await user.delete(socket.uid, uid);
diff --git a/src/user/delete.js b/src/user/delete.js
index 1560005aeb..57ed4adeb7 100644
--- a/src/user/delete.js
+++ b/src/user/delete.js
@@ -17,7 +17,13 @@ const file = require('../file');
 module.exports = function (User) {
 	const deletesInProgress = {};
 
-	User.delete = async function (callerUid, uid) {
+	User.delete = async (callerUid, uid) => {
+		await User.deleteContent(callerUid, uid);
+		await removeFromSortedSets(uid);
+		return await User.deleteAccount(uid);
+	};
+
+	User.deleteContent = async function (callerUid, uid) {
 		if (parseInt(uid, 10) <= 0) {
 			throw new Error('[[error:invalid-uid]]');
 		}
@@ -25,13 +31,10 @@ module.exports = function (User) {
 			throw new Error('[[error:already-deleting]]');
 		}
 		deletesInProgress[uid] = 'user.delete';
-		await removeFromSortedSets(uid);
 		await deletePosts(callerUid, uid);
 		await deleteTopics(callerUid, uid);
 		await deleteUploads(uid);
 		await deleteQueued(uid);
-		const userData = await User.deleteAccount(uid);
-		return userData;
 	};
 
 	async function deletePosts(callerUid, uid) {
diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl
index fb9061a700..07ab3a399c 100644
--- a/src/views/admin/manage/users.tpl
+++ b/src/views/admin/manage/users.tpl
@@ -22,6 +22,7 @@
 						<li><a href="#" class="reset-lockout"><i class="fa fa-fw fa-unlock"></i> [[admin/manage/users:reset-lockout]]</a></li>
 						<li class="divider"></li>
 						<li><a href="#" class="delete-user"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:delete]]</a></li>
+						<li><a href="#" class="delete-user-content"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:delete-content]]</a></li>
 						<li><a href="#" class="delete-user-and-content"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:purge]]</a></li>
 					</ul>
 				</div>