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 <kalista@habitica.com>
This commit is contained in:
Phillip Thelen
2025-11-04 22:35:56 +01:00
committed by GitHub
parent 215e5e1c40
commit 5ff3cc35a6
9 changed files with 225 additions and 75 deletions

View File

@@ -105,8 +105,8 @@
"start": "node --watch ./website/server/index.js", "start": "node --watch ./website/server/index.js",
"start:simple": "node ./website/server/index.js", "start:simple": "node ./website/server/index.js",
"debug": "node --watch --inspect ./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: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 ubuntu2204 --keep --dbpath mongodb-data-testing --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", "postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc", "apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh" "heroku-postbuild": ".heroku/report_deploy.sh"

View File

@@ -9,7 +9,7 @@ import {
import { import {
SPAM_MIN_EXEMPT_CONTRIB_LEVEL, SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
} from '../../../../../website/server/models/group'; } 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'; import * as email from '../../../../../website/server/libs/email';
describe('POST /chat', () => { describe('POST /chat', () => {
@@ -80,17 +80,20 @@ describe('POST /chat', () => {
member.updateOne({ 'flags.chatRevoked': false }); 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({ await member.updateOne({
'flags.chatRevoked': true, 'flags.chatRevoked': true,
}); });
const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); await expect(member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }))
.to.eventually.be.rejected.and.eql({
expect(message.message.id).to.exist; 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({ const { group, members } = await createAndPopulateGroup({
groupDetails: { groupDetails: {
name: 'Party', name: 'Party',
@@ -106,9 +109,12 @@ describe('POST /chat', () => {
'auth.timestamps.created': new Date('2022-01-01'), 'auth.timestamps.created': new Date('2022-01-01'),
}); });
const message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage }); await expect(privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage }))
.to.eventually.be.rejected.and.eql({
expect(message.message.id).to.exist; code: 401,
error: 'NotAuthorized',
message: t('chatPrivilegesRevoked'),
});
}); });
}); });
@@ -123,7 +129,7 @@ describe('POST /chat', () => {
member.updateOne({ 'flags.chatShadowMuted': false }); 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({ await member.updateOne({
'flags.chatShadowMuted': true, 'flags.chatShadowMuted': true,
}); });
@@ -131,10 +137,10 @@ describe('POST /chat', () => {
const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist; 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({ const { group, members } = await createAndPopulateGroup({
groupDetails: { groupDetails: {
name: 'Party', name: 'Party',
@@ -153,7 +159,7 @@ describe('POST /chat', () => {
const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage }); const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist; expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(0); expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE);
}); });
}); });

View File

@@ -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 () => { it('invites a user to a group by username', async () => {
const userToInvite = await generateUser(); 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 () => { it('invites a user to a group by uuid', async () => {
const userToInvite = await generateUser(); 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 () => { it('returns an error when invite is missing an email', async () => {
await expect(inviter.post(`/groups/${group._id}/invite`, { await expect(inviter.post(`/groups/${group._id}/invite`, {
emails: [{ name: 'test' }], 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 () => { it('invites users to a group by uuid and email', async () => {
const newUser = await generateUser(); const newUser = await generateUser();
const invite = await inviter.post(`/groups/${group._id}/invite`, { const invite = await inviter.post(`/groups/${group._id}/invite`, {

View File

@@ -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] }); const receiver = await generateUser({ 'inbox.blocks': [userToSendMessage._id] });
await expect(userToSendMessage.post('/members/send-private-message', { 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 receiver = await generateUser();
const sender = await generateUser({ 'inbox.blocks': [receiver._id] }); 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 }); const receiver = await generateUser({ 'inbox.optOut': true });
await expect(userToSendMessage.post('/members/send-private-message', { 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); 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({ userToSendMessage = await generateUser({
'permissions.moderator': true, 'permissions.moderator': true,
}); });
@@ -202,7 +202,7 @@ describe('POST /members/send-private-message', () => {
expect(sendersMessageInSendersInbox).to.exist; 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({ userToSendMessage = await generateUser({
'permissions.moderator': true, 'permissions.moderator': true,
}); });
@@ -229,4 +229,58 @@ describe('POST /members/send-private-message', () => {
expect(sendersMessageInReceiversInbox).to.exist; expect(sendersMessageInReceiversInbox).to.exist;
expect(sendersMessageInSendersInbox).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);
});
});
}); });

View File

@@ -1,7 +1,7 @@
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import moment from 'moment'; import moment from 'moment';
import nconf from 'nconf'; 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 Group } from '../../models/group';
import { model as User } from '../../models/user'; import { model as User } from '../../models/user';
import { import {
@@ -118,7 +118,7 @@ function getBannedWordsFromText (message) {
api.postChat = { api.postChat = {
method: 'POST', method: 'POST',
url: '/groups/:groupId/chat', url: '/groups/:groupId/chat',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders(), chatPrivilegesRequired()],
async handler (req, res) { async handler (req, res) {
const { user } = res.locals; const { user } = res.locals;
const { groupId } = req.params; const { groupId } = req.params;
@@ -161,10 +161,6 @@ api.postChat = {
throw new BadRequest(res.t('bannedSlurUsed')); 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 // prevent banned words being posted, except in private guilds/parties
// and in certain public guilds with specific topics // and in certain public guilds with specific topics
if (group.privacy === 'public' && !group.bannedWordsAllowed) { if (group.privacy === 'public' && !group.bannedWordsAllowed) {
@@ -204,7 +200,7 @@ api.postChat = {
} }
let flagCount = 0; let flagCount = 0;
if (group.privacy === 'public' && user.flags.chatShadowMuted) { if (user.flags.chatShadowMuted) {
flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE; flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE;
// Email the mods // Email the mods

View File

@@ -9,7 +9,7 @@ import pick from 'lodash/pick';
import uniqBy from 'lodash/uniqBy'; import uniqBy from 'lodash/uniqBy';
import nconf from 'nconf'; import nconf from 'nconf';
import moment from 'moment'; import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth'; import { authWithHeaders, chatPrivilegesRequired } from '../../middlewares/auth';
import { import {
model as Group, model as Group,
basicFields as basicGroupFields, basicFields as basicGroupFields,
@@ -97,8 +97,6 @@ const api = {};
* @apiError (401) {NotAuthorized} messageInsufficientGems User does not have enough gems (4) * @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} partyMustbePrivate Party must have privacy set to private
* @apiError (401) {NotAuthorized} messageGroupAlreadyInParty * @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 <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>) * @apiSuccess (201) {Object} data The created group (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
* *
@@ -1099,12 +1097,10 @@ api.removeGroupMember = {
api.inviteToGroup = { api.inviteToGroup = {
method: 'POST', method: 'POST',
url: '/groups/:groupId/invite', url: '/groups/:groupId/invite',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders(), chatPrivilegesRequired()],
async handler (req, res) { async handler (req, res) {
const { user } = res.locals; const { user } = res.locals;
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
if (user.invitesSent >= MAX_EMAIL_INVITES_BY_USER) throw new NotAuthorized(res.t('inviteLimitReached', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL })); 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 = []; const results = [];
if (uuids) { if (!user.flags.chatShadowMuted) {
const uuidInvites = uuids.map(uuid => inviteByUUID(uuid, group, user, req, res)); if (uuids) {
const uuidResults = await Promise.all(uuidInvites); const uuidInvites = uuids.map(uuid => inviteByUUID(uuid, group, user, req, res));
results.push(...uuidResults); const uuidResults = await Promise.all(uuidInvites);
} results.push(...uuidResults);
}
if (emails) { if (emails) {
const emailInvites = emails.map(invite => inviteByEmail(invite, group, user, req, res)); const emailInvites = emails.map(invite => inviteByEmail(invite, group, user, req, res));
user.invitesSent += emails.length; user.invitesSent += emails.length;
await user.save(); await user.save();
const emailResults = await Promise.all(emailInvites); const emailResults = await Promise.all(emailInvites);
results.push(...emailResults); results.push(...emailResults);
} }
if (usernames) { if (usernames) {
const usernameInvites = usernames const usernameInvites = usernames
.map(username => inviteByUserName(username, group, user, req, res)); .map(username => inviteByUserName(username, group, user, req, res));
const usernameResults = await Promise.all(usernameInvites); const usernameResults = await Promise.all(usernameInvites);
results.push(...usernameResults); 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); res.respond(200, results);

View File

@@ -3,30 +3,36 @@ import { getUserInfo, sendTxn as sendTxnEmail } from '../email'; // eslint-disab
import { sendNotification as sendPushNotification } from '../pushNotifications'; import { sendNotification as sendPushNotification } from '../pushNotifications';
export async function sentMessage (sender, receiver, message, translate) { 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; const senderName = getUserInfo(sender, ['name']).name;
if (receiver.preferences.emailNotifications.newPM !== false) { if (!fakeSending) {
sendTxnEmail(receiver, 'new-pm', [ if (receiver.preferences.emailNotifications.newPM !== false) {
{ name: 'SENDER', content: senderName }, sendTxnEmail(receiver, 'new-pm', [
]); { name: 'SENDER', content: senderName },
} ]);
}
if (receiver.preferences.pushNotifications.newPM !== false && messageSent.unformattedText) { if (receiver.preferences.pushNotifications.newPM !== false && messageSent.unformattedText) {
await sendPushNotification( await sendPushNotification(
receiver, receiver,
{ {
title: translate( title: translate(
'newPMNotificationTitle', 'newPMNotificationTitle',
{ name: getUserInfo(sender, ['name']).name }, { name: getUserInfo(sender, ['name']).name },
receiver.preferences.language, receiver.preferences.language,
), ),
message: messageSent.unformattedText, message: messageSent.unformattedText,
identifier: 'newPM', identifier: 'newPM',
category: 'newPM', category: 'newPM',
payload: { replyTo: sender._id, senderName, message: messageSent.unformattedText }, payload: { replyTo: sender._id, senderName, message: messageSent.unformattedText },
}, },
); );
}
} }
return messageSent; return messageSent;

View File

@@ -145,3 +145,15 @@ export function authWithSession (req, res, next) {
}) })
.catch(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();
};
}

View File

@@ -123,6 +123,7 @@ schema.methods.getObjectionsToInteraction = function getObjectionsToInteraction
schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, options) { schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, options) {
const sender = this; const sender = this;
const senderMsg = options.senderMsg || options.receiverMsg; const senderMsg = options.senderMsg || options.receiverMsg;
const { fakeSending } = options;
// whether to save users after sending the message, defaults to true // whether to save users after sending the message, defaults to true
const saveUsers = options.save !== false; 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 // Do not add the message twice when sending it to yourself
let newSenderMessage; let newSenderMessage;
if (!sendingToYourself) { if (!sendingToYourself || fakeSending) {
newSenderMessage = new Inbox({ newSenderMessage = new Inbox({
sent: true, sent: true,
ownerId: sender._id, ownerId: sender._id,
@@ -175,12 +176,13 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o
setUserStyles(newSenderMessage, sender); setUserStyles(newSenderMessage, sender);
} }
const promises = [newReceiverMessage.save()]; const promises = [];
if (!sendingToYourself) promises.push(newSenderMessage.save()); if (!fakeSending) promises.push(newReceiverMessage.save());
if (!sendingToYourself || fakeSending) promises.push(newSenderMessage.save());
if (saveUsers) { if (saveUsers) {
promises.push(sender.save()); promises.push(sender.save());
if (!sendingToYourself) promises.push(userToReceiveMessage.save()); if (!sendingToYourself && !fakeSending) promises.push(userToReceiveMessage.save());
} }
await Promise.all(promises); await Promise.all(promises);