From be6bbabd0e2551fbe9571dcf3ee40ad721764543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 11 Mar 2022 23:07:20 -0500 Subject: [PATCH] feat: ability to mute users new mute privilege --- .../en-GB/admin/manage/privileges.json | 1 + public/language/en-GB/admin/manage/users.json | 2 +- public/language/en-GB/error.json | 3 + public/language/en-GB/user.json | 3 + public/openapi/write.yaml | 2 + public/openapi/write/users/uid/mute.yaml | 61 +++++++++++++++++++ public/src/client/account/header.js | 45 ++++++++++++++ src/api/users.js | 47 ++++++++++++++ src/controllers/accounts/helpers.js | 3 + src/controllers/write/users.js | 10 +++ src/events.js | 2 + src/privileges/global.js | 2 + src/privileges/users.js | 16 +++++ src/routes/write/users.js | 3 + src/user/data.js | 4 +- src/user/posts.js | 12 +++- .../admin/partials/privileges/global.tpl | 27 +++++--- src/views/admin/partials/temporary-mute.tpl | 27 ++++++++ 18 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 public/openapi/write/users/uid/mute.yaml create mode 100644 src/views/admin/partials/temporary-mute.tpl diff --git a/public/language/en-GB/admin/manage/privileges.json b/public/language/en-GB/admin/manage/privileges.json index 8b38b368cd..13a38819b0 100644 --- a/public/language/en-GB/admin/manage/privileges.json +++ b/public/language/en-GB/admin/manage/privileges.json @@ -10,6 +10,7 @@ "upload-files": "Upload Files", "signature": "Signature", "ban": "Ban", + "mute": "Mute", "invite": "Invite", "search-content": "Search Content", "search-users": "Search Users", diff --git a/public/language/en-GB/admin/manage/users.json b/public/language/en-GB/admin/manage/users.json index 2a3c0c4829..d5c9914afc 100644 --- a/public/language/en-GB/admin/manage/users.json +++ b/public/language/en-GB/admin/manage/users.json @@ -62,7 +62,7 @@ "create.password": "Password", "create.password-confirm": "Confirm Password", - "temp-ban.length": "Ban Length", + "temp-ban.length": "Length", "temp-ban.reason": "Reason (Optional)", "temp-ban.hours": "Hours", "temp-ban.days": "Days", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 1f965d5ea5..e641d741a0 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -124,6 +124,9 @@ "already-unbookmarked": "You have already unbookmarked this post", "cant-ban-other-admins": "You can't ban other admins!", + "cant-mute-other-admins": "You can't mute other admins!", + "user-muted-for-hours": "You have been muted, you will be able to post in %1 hour(s)", + "user-muted-for-minutes": "You have been muted, you will be able to post in %1 minute(s)", "cant-make-banned-users-admin": "You can't make banned users admin.", "cant-remove-last-admin": "You are the only administrator. Add another user as an administrator before removing yourself as admin", "account-deletion-disabled": "Account deletion is disabled", diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index 20f47a6d95..58c3759fe8 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -13,6 +13,8 @@ "ban_account": "Ban Account", "ban_account_confirm": "Do you really want to ban this user?", "unban_account": "Unban Account", + "mute_account": "Mute Account", + "unmute_account": "Unmute Account", "delete_account": "Delete Account", "delete_account_as_admin": "Delete Account", "delete_content": "Delete Account Content", @@ -171,6 +173,7 @@ "info.banned-permanently": "Banned permanently", "info.banned-reason-label": "Reason", "info.banned-no-reason": "No reason given.", + "info.muted-no-reason": "No reason given.", "info.username-history": "Username History", "info.email-history": "Email History", "info.moderation-note": "Moderation Note", diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 1e73eb4cf9..eb74cef2d9 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -68,6 +68,8 @@ paths: $ref: 'write/users/uid/follow.yaml' /users/{uid}/ban: $ref: 'write/users/uid/ban.yaml' + /users/{uid}/mute: + $ref: 'write/users/uid/mute.yaml' /users/{uid}/tokens: $ref: 'write/users/uid/tokens.yaml' /users/{uid}/tokens/{token}: diff --git a/public/openapi/write/users/uid/mute.yaml b/public/openapi/write/users/uid/mute.yaml new file mode 100644 index 0000000000..7fa84c9b22 --- /dev/null +++ b/public/openapi/write/users/uid/mute.yaml @@ -0,0 +1,61 @@ +put: + tags: + - users + summary: mute a user + parameters: + - in: path + name: uid + schema: + type: integer + required: true + description: uid of the user to mute + example: 2 + requestBody: + content: + application/json: + schema: + type: object + properties: + until: + type: number + description: UNIX timestamp of the mute expiry + example: 1585775608076 + reason: + type: string + example: the reason for the mute + responses: + '200': + description: successfully muted user + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object +delete: + tags: + - users + summary: unmute a user + parameters: + - in: path + name: uid + schema: + type: integer + required: true + description: uid of the user to unmute + example: 2 + responses: + '200': + description: successfully unmuted user + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object \ No newline at end of file diff --git a/public/src/client/account/header.js b/public/src/client/account/header.js index c7880123c9..a793403bfc 100644 --- a/public/src/client/account/header.js +++ b/public/src/client/account/header.js @@ -54,7 +54,9 @@ define('forum/account/header', [ components.get('account/ban').on('click', function () { banAccount(ajaxify.data.theirid); }); + components.get('account/mute').on('click', muteAccount); components.get('account/unban').on('click', unbanAccount); + components.get('account/unmute').on('click', unmuteAccount); components.get('account/delete-account').on('click', handleDeleteEvent.bind(null, 'account')); components.get('account/delete-content').on('click', handleDeleteEvent.bind(null, 'content')); components.get('account/delete-all').on('click', handleDeleteEvent.bind(null, 'purge')); @@ -177,6 +179,49 @@ define('forum/account/header', [ }).catch(alerts.error); } + function muteAccount() { + Benchpress.render('admin/partials/temporary-mute', {}).then(function (html) { + bootbox.dialog({ + className: 'mute-modal', + title: '[[user:mute_account]]', + message: html, + show: true, + buttons: { + close: { + label: '[[global:close]]', + className: 'btn-link', + }, + submit: { + label: '[[user:mute_account]]', + callback: function () { + const formData = $('.mute-modal form').serializeArray().reduce(function (data, cur) { + data[cur.name] = cur.value; + return data; + }, {}); + + const until = formData.length > 0 ? ( + Date.now() + (formData.length * 1000 * 60 * 60 * (parseInt(formData.unit, 10) ? 24 : 1)) + ) : 0; + + api.put('/users/' + ajaxify.data.theirid + '/mute', { + until: until, + reason: formData.reason || '', + }).then(() => { + ajaxify.refresh(); + }).catch(alerts.error); + }, + }, + }, + }); + }); + } + + function unmuteAccount() { + api.del('/users/' + ajaxify.data.theirid + '/mute').then(() => { + ajaxify.refresh(); + }).catch(alerts.error); + } + function flagAccount() { require(['flags'], function (flags) { flags.showFlagModal({ diff --git a/src/api/users.js b/src/api/users.js index 1803259b39..4cf9622d70 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -225,6 +225,53 @@ usersAPI.unban = async function (caller, data) { }); }; +usersAPI.mute = async function (caller, data) { + if (!await privileges.users.hasMutePrivilege(caller.uid)) { + throw new Error('[[error:no-privileges]]'); + } else if (await user.isAdministrator(data.uid)) { + throw new Error('[[error:cant-mute-other-admins]]'); + } + await db.setObject(`user:${data.uid}`, { + mutedUntil: data.until, + mutedReason: data.reason || '[[user:info.muted-no-reason]]', + }); + + await events.log({ + type: 'user-mute', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + reason: data.reason || undefined, + }); + plugins.hooks.fire('action:user.muted', { + callerUid: caller.uid, + ip: caller.ip, + uid: data.uid, + until: data.until > 0 ? data.until : undefined, + reason: data.reason || undefined, + }); +}; + +usersAPI.unmute = async function (caller, data) { + if (!await privileges.users.hasMutePrivilege(caller.uid)) { + throw new Error('[[error:no-privileges]]'); + } + + await db.deleteObjectFields(`user:${data.uid}`, ['mutedUntil', 'mutedReason']); + + await events.log({ + type: 'user-unmute', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + }); + plugins.hooks.fire('action:user.unmuted', { + callerUid: caller.uid, + ip: caller.ip, + uid: data.uid, + }); +}; + async function isPrivilegedOrSelfAndPasswordMatch(caller, data) { const { uid } = caller; const isSelf = parseInt(uid, 10) === parseInt(data.uid, 10); diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index d845ebdbb0..e151f7c731 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -73,6 +73,7 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {}) userData.isSelfOrAdminOrGlobalModerator = isSelf || isAdmin || isGlobalModerator; userData.canEdit = results.canEdit; userData.canBan = results.canBanUser; + userData.canMute = results.canMuteUser; userData.canFlag = (await privileges.users.canFlag(callerUID, userData.uid)).flag; userData.canChangePassword = isAdmin || (isSelf && !meta.config['password:disableEdit']); userData.isSelf = isSelf; @@ -95,6 +96,7 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {}) userData.sso = results.sso.associations; userData.banned = Boolean(userData.banned); + userData.muted = parseInt(userData.mutedUntil, 10) > Date.now(); userData.website = escape(userData.website); userData.websiteLink = !userData.website.startsWith('http') ? `http://${userData.website}` : userData.website; userData.websiteName = userData.website.replace(validator.escape('http://'), '').replace(validator.escape('https://'), ''); @@ -144,6 +146,7 @@ async function getAllData(uid, callerUID) { sso: plugins.hooks.fire('filter:auth.list', { uid: uid, associations: [] }), canEdit: privileges.users.canEdit(callerUID, uid), canBanUser: privileges.users.canBanUser(callerUID, uid), + canMuteUser: privileges.users.canMuteUser(callerUID, uid), isBlocked: user.blocks.is(uid, callerUID), canViewInfo: privileges.global.can('view:users:info', callerUID), hasPrivateChat: messaging.hasPrivateChat(callerUID, uid), diff --git a/src/controllers/write/users.js b/src/controllers/write/users.js index 4cea3af1da..ddf1b5b93c 100644 --- a/src/controllers/write/users.js +++ b/src/controllers/write/users.js @@ -111,6 +111,16 @@ Users.unban = async (req, res) => { helpers.formatApiResponse(200, res); }; +Users.mute = async (req, res) => { + await api.users.mute(req, { ...req.body, uid: req.params.uid }); + helpers.formatApiResponse(200, res); +}; + +Users.unmute = async (req, res) => { + await api.users.unmute(req, { ...req.body, uid: req.params.uid }); + helpers.formatApiResponse(200, res); +}; + Users.generateToken = async (req, res) => { await hasAdminPrivilege(req.uid, 'settings'); if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10)) { diff --git a/src/events.js b/src/events.js index 69da2ef272..b4ce5e3fbd 100644 --- a/src/events.js +++ b/src/events.js @@ -36,6 +36,8 @@ events.types = [ 'user-removeAdmin', 'user-ban', 'user-unban', + 'user-mute', + 'user-unmute', 'user-delete', 'user-deleteAccount', 'user-deleteContent', diff --git a/src/privileges/global.js b/src/privileges/global.js index 00cf0cce05..5d8a17431a 100644 --- a/src/privileges/global.js +++ b/src/privileges/global.js @@ -26,6 +26,7 @@ privsGlobal.privilegeLabels = [ { name: '[[admin/manage/privileges:view-groups]]' }, { name: '[[admin/manage/privileges:allow-local-login]]' }, { name: '[[admin/manage/privileges:ban]]' }, + { name: '[[admin/manage/privileges:mute]]' }, { name: '[[admin/manage/privileges:view-users-info]]' }, ]; @@ -44,6 +45,7 @@ privsGlobal.userPrivilegeList = [ 'view:groups', 'local:login', 'ban', + 'mute', 'view:users:info', ]; diff --git a/src/privileges/users.js b/src/privileges/users.js index 88f28ea5c7..ac3c0ca1c7 100644 --- a/src/privileges/users.js +++ b/src/privileges/users.js @@ -109,6 +109,21 @@ privsUsers.canBanUser = async function (callerUid, uid) { return data.canBan; }; +privsUsers.canMuteUser = async function (callerUid, uid) { + const privsGlobal = require('./global'); + const [canMute, isTargetAdmin] = await Promise.all([ + privsGlobal.can('mute', callerUid), + privsUsers.isAdministrator(uid), + ]); + + const data = await plugins.hooks.fire('filter:user.canMuteUser', { + canMute: canMute && !isTargetAdmin, + callerUid: callerUid, + uid: uid, + }); + return data.canMute; +}; + privsUsers.canFlag = async function (callerUid, uid) { const [userReputation, targetPrivileged, reporterPrivileged] = await Promise.all([ user.getUserField(callerUid, 'reputation'), @@ -126,6 +141,7 @@ privsUsers.canFlag = async function (callerUid, uid) { }; privsUsers.hasBanPrivilege = async uid => await hasGlobalPrivilege('ban', uid); +privsUsers.hasMutePrivilege = async uid => await hasGlobalPrivilege('mute', uid); privsUsers.hasInvitePrivilege = async uid => await hasGlobalPrivilege('invite', uid); async function hasGlobalPrivilege(privilege, uid) { diff --git a/src/routes/write/users.js b/src/routes/write/users.js index d3d3b0017d..6f22fa6166 100644 --- a/src/routes/write/users.js +++ b/src/routes/write/users.js @@ -36,6 +36,9 @@ function authenticatedRoutes() { setupApiRoute(router, 'put', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.ban); setupApiRoute(router, 'delete', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.unban); + setupApiRoute(router, 'put', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.mute); + setupApiRoute(router, 'delete', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.unmute); + setupApiRoute(router, 'post', '/:uid/tokens', [...middlewares, middleware.assert.user], controllers.write.users.generateToken); setupApiRoute(router, 'delete', '/:uid/tokens/:token', [...middlewares, middleware.assert.user], controllers.write.users.deleteToken); diff --git a/src/user/data.js b/src/user/data.js index d784a7efe7..a58b95669a 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -15,7 +15,7 @@ const intFields = [ 'uid', 'postcount', 'topiccount', 'reputation', 'profileviews', 'banned', 'banned:expire', 'email:confirmed', 'joindate', 'lastonline', 'lastqueuetime', 'lastposttime', 'followingCount', 'followerCount', - 'blocksCount', 'passwordExpiry', + 'blocksCount', 'passwordExpiry', 'mutedUntil', ]; module.exports = function (User) { @@ -25,7 +25,7 @@ module.exports = function (User) { 'aboutme', 'signature', 'uploadedpicture', 'profileviews', 'reputation', 'postcount', 'topiccount', 'lastposttime', 'banned', 'banned:expire', 'status', 'flags', 'followerCount', 'followingCount', 'cover:url', - 'cover:position', 'groupTitle', + 'cover:position', 'groupTitle', 'mutedUntil', 'mutedReason', ]; User.guestData = { diff --git a/src/user/posts.js b/src/user/posts.js index 65d80849ef..a49ced7fd3 100644 --- a/src/user/posts.js +++ b/src/user/posts.js @@ -18,7 +18,7 @@ module.exports = function (User) { return; } const [userData, isAdminOrMod] = await Promise.all([ - User.getUserFields(uid, ['uid', 'banned', 'joindate', 'email', 'reputation'].concat([field])), + User.getUserFields(uid, ['uid', 'banned', 'mutedUntil', 'joindate', 'email', 'reputation'].concat([field])), privileges.categories.isAdminOrMod(cid, uid), ]); @@ -35,6 +35,16 @@ module.exports = function (User) { } const now = Date.now(); + if (userData.mutedUntil > now) { + let muteLeft = ((userData.mutedUntil - now) / (1000 * 60)); + if (muteLeft > 60) { + muteLeft = (muteLeft / 60).toFixed(0); + throw new Error(`[[error:user-muted-for-hours, ${muteLeft}]]`); + } else { + throw new Error(`[[error:user-muted-for-minutes, ${muteLeft.toFixed(0)}]]`); + } + } + if (now - userData.joindate < meta.config.initialPostDelay * 1000) { throw new Error(`[[error:user-too-new, ${meta.config.initialPostDelay}]]`); } diff --git a/src/views/admin/partials/privileges/global.tpl b/src/views/admin/partials/privileges/global.tpl index 28271a215d..33e8d102a5 100644 --- a/src/views/admin/partials/privileges/global.tpl +++ b/src/views/admin/partials/privileges/global.tpl @@ -3,15 +3,13 @@ {{{ if !isAdminPriv }}} - - - [[admin/manage/categories:privileges.section-posting]] - - - [[admin/manage/categories:privileges.section-viewing]] - - - [[admin/manage/categories:privileges.section-moderation]] + + + + + + + {{{ end }}} @@ -65,9 +63,18 @@ + {{{ if !isAdminPriv }}} - + + {{{ end }}} diff --git a/src/views/admin/partials/temporary-mute.tpl b/src/views/admin/partials/temporary-mute.tpl new file mode 100644 index 0000000000..31bcd631f3 --- /dev/null +++ b/src/views/admin/partials/temporary-mute.tpl @@ -0,0 +1,27 @@ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +    + + +
+
+
+
+ + + + + + +
[[admin/manage/categories:privileges.section-user]] [[admin/manage/privileges:select-clear-all]]