From 5ff3cc35a67c18928805f94fa84b0730f67f8996 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 4 Nov 2025 22:35:56 +0100 Subject: [PATCH] Improvements to shadow muting (#15543) * fix test wording * make shadow mute work for dms * shadow mute chat messages * shadow mute invites * oops * refactor mute handling into middleware * correctly throw error * fix * test(chat): expect errors when muted Also fixes the Linux version in the mongo commands. Again. wtf --------- Co-authored-by: Kalista Payne --- package.json | 4 +- .../api/v3/integration/chat/POST-chat.test.js | 32 ++++++---- .../groups/POST-groups_invite.test.js | 62 ++++++++++++++++++ .../inbox/POST-send_private_message.test.js | 64 +++++++++++++++++-- website/server/controllers/api-v3/chat.js | 10 +-- website/server/controllers/api-v3/groups.js | 58 ++++++++++------- website/server/libs/inbox/index.js | 48 ++++++++------ website/server/middlewares/auth.js | 12 ++++ website/server/models/user/methods.js | 10 +-- 9 files changed, 225 insertions(+), 75 deletions(-) diff --git a/package.json b/package.json index be118cbfe6..928f76afee 100644 --- a/package.json +++ b/package.json @@ -105,8 +105,8 @@ "start": "node --watch ./website/server/index.js", "start:simple": "node ./website/server/index.js", "debug": "node --watch --inspect ./website/server/index.js", - "mongo:dev": "run-rs -v 7.0.23 -l ubuntu2204 --keep --dbpath mongodb-data --number 1 --quiet", - "mongo:test": "run-rs -v 7.0.23 -l ubuntu2204 --keep --dbpath mongodb-data-testing --number 1 --quiet", + "mongo:dev": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data --number 1 --quiet", + "mongo:test": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data-testing --number 1 --quiet", "postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install", "apidoc": "gulp apidoc", "heroku-postbuild": ".heroku/report_deploy.sh" diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index f16cbea6f9..c32804ed10 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -9,7 +9,7 @@ import { import { SPAM_MIN_EXEMPT_CONTRIB_LEVEL, } from '../../../../../website/server/models/group'; -import { MAX_MESSAGE_LENGTH } from '../../../../../website/common/script/constants'; +import { MAX_MESSAGE_LENGTH, CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../../../website/common/script/constants'; import * as email from '../../../../../website/server/libs/email'; describe('POST /chat', () => { @@ -80,17 +80,20 @@ describe('POST /chat', () => { member.updateOne({ 'flags.chatRevoked': false }); }); - it('does not error when chat privileges are revoked when sending a message to a private guild', async () => { + it('errors when chat privileges are revoked when sending a message to a private guild', async () => { await member.updateOne({ 'flags.chatRevoked': true, }); - const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); - - expect(message.message.id).to.exist; + await expect(member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('chatPrivilegesRevoked'), + }); }); - it('does not error when chat privileges are revoked when sending a message to a party', async () => { + it('errors when chat privileges are revoked when sending a message to a party', async () => { const { group, members } = await createAndPopulateGroup({ groupDetails: { name: 'Party', @@ -106,9 +109,12 @@ describe('POST /chat', () => { 'auth.timestamps.created': new Date('2022-01-01'), }); - const message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage }); - - expect(message.message.id).to.exist; + await expect(privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('chatPrivilegesRevoked'), + }); }); }); @@ -123,7 +129,7 @@ describe('POST /chat', () => { member.updateOne({ 'flags.chatShadowMuted': false }); }); - it('creates a chat with zero flagCount when sending a message to a private guild', async () => { + it('creates a chat with flagCount set when sending a message to a private guild', async () => { await member.updateOne({ 'flags.chatShadowMuted': true, }); @@ -131,10 +137,10 @@ describe('POST /chat', () => { const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); expect(message.message.id).to.exist; - expect(message.message.flagCount).to.eql(0); + expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE); }); - it('creates a chat with zero flagCount when sending a message to a party', async () => { + it('creates a chat with flagCount set when sending a message to a party', async () => { const { group, members } = await createAndPopulateGroup({ groupDetails: { name: 'Party', @@ -153,7 +159,7 @@ describe('POST /chat', () => { const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage }); expect(message.message.id).to.exist; - expect(message.message.flagCount).to.eql(0); + expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE); }); }); diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index df343c8324..f98d4fd1c4 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -61,6 +61,24 @@ describe('Post /groups/:groupId/invite', () => { }); }); + it('fakes sending an invite if user is shadow muted', async () => { + const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true }); + const userToInvite = await generateUser(); + + const response = await inviterMuted.post(`/groups/${group._id}/invite`, { + usernames: [userToInvite.auth.local.lowerCaseUsername], + }); + expect(response).to.be.an('Array'); + expect(response[0]).to.have.all.keys(['_id', 'id', 'name', 'inviter']); + expect(response[0]._id).to.be.a('String'); + expect(response[0].id).to.eql(group._id); + expect(response[0].name).to.eql(groupName); + expect(response[0].inviter).to.eql(inviter._id); + + await expect(userToInvite.get('/user')) + .to.eventually.not.have.nested.property('invitations.parties[0].id', group._id); + }); + it('invites a user to a group by username', async () => { const userToInvite = await generateUser(); @@ -209,6 +227,24 @@ describe('Post /groups/:groupId/invite', () => { }); }); + it('fakes sending an invite if user is shadow muted', async () => { + const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true }); + const userToInvite = await generateUser(); + + const response = await inviterMuted.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + }); + expect(response).to.be.an('Array'); + expect(response[0]).to.have.all.keys(['_id', 'id', 'name', 'inviter']); + expect(response[0]._id).to.be.a('String'); + expect(response[0].id).to.eql(group._id); + expect(response[0].name).to.eql(groupName); + expect(response[0].inviter).to.eql(inviter._id); + + await expect(userToInvite.get('/user')) + .to.eventually.not.have.nested.property('invitations.parties[0].id', group._id); + }); + it('invites a user to a group by uuid', async () => { const userToInvite = await generateUser(); @@ -281,6 +317,19 @@ describe('Post /groups/:groupId/invite', () => { }); }); + it('fakes sending invite when inviter is shadow muted', async () => { + const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true }); + const res = await inviterMuted.post(`/groups/${group._id}/invite`, { + emails: [testInvite], + inviter: 'inviter name', + }); + + const updatedUser = await inviterMuted.sync(); + + expect(res).to.exist; + expect(updatedUser.invitesSent).to.eql(1); + }); + it('returns an error when invite is missing an email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { emails: [{ name: 'test' }], @@ -405,6 +454,19 @@ describe('Post /groups/:groupId/invite', () => { }); }); + it('fakes sending an invite if user is shadow muted', async () => { + const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true }); + const newUser = await generateUser(); + const invite = await inviterMuted.post(`/groups/${group._id}/invite`, { + uuids: [newUser._id], + emails: [{ name: 'test', email: 'test@habitica.com' }], + }); + const invitedUser = await newUser.get('/user'); + + expect(invitedUser.invitations.parties[0]).to.not.exist; + expect(invite).to.exist; + }); + it('invites users to a group by uuid and email', async () => { const newUser = await generateUser(); const invite = await inviter.post(`/groups/${group._id}/invite`, { diff --git a/test/api/v3/integration/inbox/POST-send_private_message.test.js b/test/api/v3/integration/inbox/POST-send_private_message.test.js index a51ca111b8..e389a89714 100644 --- a/test/api/v3/integration/inbox/POST-send_private_message.test.js +++ b/test/api/v3/integration/inbox/POST-send_private_message.test.js @@ -43,7 +43,7 @@ describe('POST /members/send-private-message', () => { }); }); - it('returns error when to user has blocked the sender', async () => { + it('returns error when recipient has blocked the sender', async () => { const receiver = await generateUser({ 'inbox.blocks': [userToSendMessage._id] }); await expect(userToSendMessage.post('/members/send-private-message', { @@ -56,7 +56,7 @@ describe('POST /members/send-private-message', () => { }); }); - it('returns error when sender has blocked to user', async () => { + it('returns error when sender has blocked recipient', async () => { const receiver = await generateUser(); const sender = await generateUser({ 'inbox.blocks': [receiver._id] }); @@ -70,7 +70,7 @@ describe('POST /members/send-private-message', () => { }); }); - it('returns error when to user has opted out of messaging', async () => { + it('returns error when recipient has opted out of messaging', async () => { const receiver = await generateUser({ 'inbox.optOut': true }); await expect(userToSendMessage.post('/members/send-private-message', { @@ -174,7 +174,7 @@ describe('POST /members/send-private-message', () => { expect(notification.data.excerpt).to.equal(messageExcerpt); }); - it('allows admin to send when sender has blocked the admin', async () => { + it('allows admin to send when recipient has blocked the admin', async () => { userToSendMessage = await generateUser({ 'permissions.moderator': true, }); @@ -202,7 +202,7 @@ describe('POST /members/send-private-message', () => { expect(sendersMessageInSendersInbox).to.exist; }); - it('allows admin to send when to user has opted out of messaging', async () => { + it('allows admin to send when recipient has opted out of messaging', async () => { userToSendMessage = await generateUser({ 'permissions.moderator': true, }); @@ -229,4 +229,58 @@ describe('POST /members/send-private-message', () => { expect(sendersMessageInReceiversInbox).to.exist; expect(sendersMessageInSendersInbox).to.exist; }); + + describe('sender is shadow muted', () => { + beforeEach(async () => { + userToSendMessage = await generateUser({ + 'flags.chatShadowMuted': true, + }); + }); + + it('does not save the message in the receiver inbox', async () => { + const receiver = await generateUser(); + + const response = await userToSendMessage.post('/members/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + }); + + expect(response.message.uuid).to.equal(receiver._id); + + const updatedReceiver = await receiver.get('/user'); + const updatedSender = await userToSendMessage.get('/user'); + + const sendersMessageInReceiversInbox = _.find( + updatedReceiver.inbox.messages, + message => message.uuid === userToSendMessage._id && message.text === messageToSend, + ); + + const sendersMessageInSendersInbox = _.find( + updatedSender.inbox.messages, + message => message.uuid === receiver._id && message.text === messageToSend, + ); + + expect(sendersMessageInReceiversInbox).to.not.exist; + expect(sendersMessageInSendersInbox).to.exist; + }); + + it('does not save the message message twice if recipient is sender', async () => { + const response = await userToSendMessage.post('/members/send-private-message', { + message: messageToSend, + toUserId: userToSendMessage._id, + }); + + expect(response.message.uuid).to.equal(userToSendMessage._id); + + const updatedSender = await userToSendMessage.get('/user'); + + const sendersMessageInSendersInbox = _.find( + updatedSender.inbox.messages, + message => message.uuid === userToSendMessage._id && message.text === messageToSend, + ); + + expect(sendersMessageInSendersInbox).to.exist; + expect(Object.keys(updatedSender.inbox.messages).length).to.equal(1); + }); + }); }); diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index 255d8c30e1..65dce42abe 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -1,7 +1,7 @@ import pick from 'lodash/pick'; import moment from 'moment'; import nconf from 'nconf'; -import { authWithHeaders } from '../../middlewares/auth'; +import { authWithHeaders, chatPrivilegesRequired } from '../../middlewares/auth'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import { @@ -118,7 +118,7 @@ function getBannedWordsFromText (message) { api.postChat = { method: 'POST', url: '/groups/:groupId/chat', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), chatPrivilegesRequired()], async handler (req, res) { const { user } = res.locals; const { groupId } = req.params; @@ -161,10 +161,6 @@ api.postChat = { throw new BadRequest(res.t('bannedSlurUsed')); } - if (group.privacy === 'public' && user.flags.chatRevoked) { - throw new NotAuthorized(res.t('chatPrivilegesRevoked')); - } - // prevent banned words being posted, except in private guilds/parties // and in certain public guilds with specific topics if (group.privacy === 'public' && !group.bannedWordsAllowed) { @@ -204,7 +200,7 @@ api.postChat = { } let flagCount = 0; - if (group.privacy === 'public' && user.flags.chatShadowMuted) { + if (user.flags.chatShadowMuted) { flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE; // Email the mods diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 7d0a4fb49a..159adb8466 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -9,7 +9,7 @@ import pick from 'lodash/pick'; import uniqBy from 'lodash/uniqBy'; import nconf from 'nconf'; import moment from 'moment'; -import { authWithHeaders } from '../../middlewares/auth'; +import { authWithHeaders, chatPrivilegesRequired } from '../../middlewares/auth'; import { model as Group, basicFields as basicGroupFields, @@ -97,8 +97,6 @@ const api = {}; * @apiError (401) {NotAuthorized} messageInsufficientGems User does not have enough gems (4) * @apiError (401) {NotAuthorized} partyMustbePrivate Party must have privacy set to private * @apiError (401) {NotAuthorized} messageGroupAlreadyInParty - * @apiError (401) {NotAuthorized} chatPrivilegesRevoked You cannot do this because your chat - privileges have been removed... * * @apiSuccess (201) {Object} data The created group (See /website/server/models/group.js) * @@ -1099,12 +1097,10 @@ api.removeGroupMember = { api.inviteToGroup = { method: 'POST', url: '/groups/:groupId/invite', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), chatPrivilegesRequired()], async handler (req, res) { const { user } = res.locals; - if (user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked')); - req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); if (user.invitesSent >= MAX_EMAIL_INVITES_BY_USER) throw new NotAuthorized(res.t('inviteLimitReached', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL })); @@ -1131,25 +1127,41 @@ api.inviteToGroup = { const results = []; - if (uuids) { - const uuidInvites = uuids.map(uuid => inviteByUUID(uuid, group, user, req, res)); - const uuidResults = await Promise.all(uuidInvites); - results.push(...uuidResults); - } + if (!user.flags.chatShadowMuted) { + if (uuids) { + const uuidInvites = uuids.map(uuid => inviteByUUID(uuid, group, user, req, res)); + const uuidResults = await Promise.all(uuidInvites); + results.push(...uuidResults); + } - if (emails) { - const emailInvites = emails.map(invite => inviteByEmail(invite, group, user, req, res)); - user.invitesSent += emails.length; - await user.save(); - const emailResults = await Promise.all(emailInvites); - results.push(...emailResults); - } + if (emails) { + const emailInvites = emails.map(invite => inviteByEmail(invite, group, user, req, res)); + user.invitesSent += emails.length; + await user.save(); + const emailResults = await Promise.all(emailInvites); + results.push(...emailResults); + } - if (usernames) { - const usernameInvites = usernames - .map(username => inviteByUserName(username, group, user, req, res)); - const usernameResults = await Promise.all(usernameInvites); - results.push(...usernameResults); + if (usernames) { + const usernameInvites = usernames + .map(username => inviteByUserName(username, group, user, req, res)); + const usernameResults = await Promise.all(usernameInvites); + results.push(...usernameResults); + } + } else { + const fakeCount = (uuids ? uuids.length : 0) + + (emails ? emails.length : 0) + + (usernames ? usernames.length : 0); + results.push(...new Array(fakeCount).fill({ + id: group._id, + _id: group._id, + name: group.name, + inviter: user._id, + })); + if (emails) { + user.invitesSent += emails.length; + await user.save(); + } } res.respond(200, results); diff --git a/website/server/libs/inbox/index.js b/website/server/libs/inbox/index.js index 1117c7cb47..3cc2c2228b 100644 --- a/website/server/libs/inbox/index.js +++ b/website/server/libs/inbox/index.js @@ -3,30 +3,36 @@ import { getUserInfo, sendTxn as sendTxnEmail } from '../email'; // eslint-disab import { sendNotification as sendPushNotification } from '../pushNotifications'; export async function sentMessage (sender, receiver, message, translate) { - const messageSent = await sender.sendMessage(receiver, { receiverMsg: message }); + const fakeSending = sender.flags.chatShadowMuted; + const messageSent = await sender.sendMessage(receiver, { + receiverMsg: message, + fakeSending, + }); const senderName = getUserInfo(sender, ['name']).name; - if (receiver.preferences.emailNotifications.newPM !== false) { - sendTxnEmail(receiver, 'new-pm', [ - { name: 'SENDER', content: senderName }, - ]); - } + if (!fakeSending) { + if (receiver.preferences.emailNotifications.newPM !== false) { + sendTxnEmail(receiver, 'new-pm', [ + { name: 'SENDER', content: senderName }, + ]); + } - if (receiver.preferences.pushNotifications.newPM !== false && messageSent.unformattedText) { - await sendPushNotification( - receiver, - { - title: translate( - 'newPMNotificationTitle', - { name: getUserInfo(sender, ['name']).name }, - receiver.preferences.language, - ), - message: messageSent.unformattedText, - identifier: 'newPM', - category: 'newPM', - payload: { replyTo: sender._id, senderName, message: messageSent.unformattedText }, - }, - ); + if (receiver.preferences.pushNotifications.newPM !== false && messageSent.unformattedText) { + await sendPushNotification( + receiver, + { + title: translate( + 'newPMNotificationTitle', + { name: getUserInfo(sender, ['name']).name }, + receiver.preferences.language, + ), + message: messageSent.unformattedText, + identifier: 'newPM', + category: 'newPM', + payload: { replyTo: sender._id, senderName, message: messageSent.unformattedText }, + }, + ); + } } return messageSent; diff --git a/website/server/middlewares/auth.js b/website/server/middlewares/auth.js index 0ff589b441..95e025946e 100644 --- a/website/server/middlewares/auth.js +++ b/website/server/middlewares/auth.js @@ -145,3 +145,15 @@ export function authWithSession (req, res, next) { }) .catch(next); } + +export function chatPrivilegesRequired () { + return function chatPrivilegesRequiredHandler (req, res, next) { + const { user } = res.locals; + + if (user.flags.chatRevoked) { + throw new NotAuthorized(res.t('chatPrivilegesRevoked')); + } + + return next(); + }; +} diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index d9f478a932..0cccfac71b 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -123,6 +123,7 @@ schema.methods.getObjectionsToInteraction = function getObjectionsToInteraction schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, options) { const sender = this; const senderMsg = options.senderMsg || options.receiverMsg; + const { fakeSending } = options; // whether to save users after sending the message, defaults to true const saveUsers = options.save !== false; @@ -165,7 +166,7 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o // Do not add the message twice when sending it to yourself let newSenderMessage; - if (!sendingToYourself) { + if (!sendingToYourself || fakeSending) { newSenderMessage = new Inbox({ sent: true, ownerId: sender._id, @@ -175,12 +176,13 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o setUserStyles(newSenderMessage, sender); } - const promises = [newReceiverMessage.save()]; - if (!sendingToYourself) promises.push(newSenderMessage.save()); + const promises = []; + if (!fakeSending) promises.push(newReceiverMessage.save()); + if (!sendingToYourself || fakeSending) promises.push(newSenderMessage.save()); if (saveUsers) { promises.push(sender.save()); - if (!sendingToYourself) promises.push(userToReceiveMessage.save()); + if (!sendingToYourself && !fakeSending) promises.push(userToReceiveMessage.save()); } await Promise.all(promises);