diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index 4d12871055..7770fb0127 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -4,10 +4,15 @@ import { sleep, server, } from '../../../../helpers/api-v3-integration.helper'; +import { + SPAM_MESSAGE_LIMIT, + SPAM_MIN_EXEMPT_CONTRIB_LEVEL, + TAVERN_ID, +} from '../../../../../website/server/models/group'; import { v4 as generateUUID } from 'uuid'; describe('POST /chat', () => { - let user, groupWithChat, userWithChatRevoked, member; + let user, groupWithChat, member, additionalMember; let testMessage = 'Test Message'; let testBannedWordMessage = 'TEST_PLACEHOLDER_SWEAR_WORD_HERE'; @@ -23,8 +28,8 @@ describe('POST /chat', () => { user = groupLeader; groupWithChat = group; - userWithChatRevoked = await members[0].update({'flags.chatRevoked': true}); member = members[0]; + additionalMember = members[1]; }); it('Returns an error when no message is provided', async () => { @@ -63,6 +68,7 @@ describe('POST /chat', () => { }); it('returns an error when chat privileges are revoked when sending a message to a public guild', async () => { + let userWithChatRevoked = await member.update({'flags.chatRevoked': true}); await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -264,4 +270,30 @@ describe('POST /chat', () => { expect(message.message.id).to.exist; expect(memberWithNotification.newMessages[`${group._id}`]).to.exist; }); + + context('Spam prevention', () => { + it('Returns an error when the user has been posting too many messages', async () => { + // Post as many messages are needed to reach the spam limit + for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) { + let result = await additionalMember.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage }); // eslint-disable-line no-await-in-loop + expect(result.message.id).to.exist; + } + + await expect(additionalMember.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupChatSpam'), + }); + }); + + it('contributor should not receive spam alert', async () => { + let userSocialite = await member.update({'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL, 'flags.chatRevoked': false}); + + // Post 1 more message than the spam limit to ensure they do not reach the limit + for (let i = 0; i < SPAM_MESSAGE_LIMIT + 1; i++) { + let result = await userSocialite.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage }); // eslint-disable-line no-await-in-loop + expect(result.message.id).to.exist; + } + }); + }); }); diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index 1ce5254461..91d04b4b1f 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -2,7 +2,13 @@ import moment from 'moment'; import { v4 as generateUUID } from 'uuid'; import validator from 'validator'; import { sleep } from '../../../../helpers/api-unit.helper'; -import { model as Group, INVITES_LIMIT } from '../../../../../website/server/models/group'; +import { + SPAM_MESSAGE_LIMIT, + SPAM_MIN_EXEMPT_CONTRIB_LEVEL, + SPAM_WINDOW_LENGTH, + INVITES_LIMIT, + model as Group, +} from '../../../../../website/server/models/group'; import { model as User } from '../../../../../website/server/models/user'; import { quests as questScrolls } from '../../../../../website/common/script/content'; import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook'; @@ -595,6 +601,99 @@ describe('Group Model', () => { }); }); + describe('#checkChatSpam', () => { + let testUser, testTime, tavern; + let testUserID = '1'; + beforeEach(async () => { + testTime = Date.now(); + + tavern = new Group({ + name: 'test tavern', + type: 'guild', + privacy: 'public', + }); + tavern._id = TAVERN_ID; + + testUser = { + _id: testUserID, + }; + }); + + function generateTestMessage (overrides = {}) { + return Object.assign({}, { + text: 'test message', + uuid: testUserID, + timestamp: testTime, + }, overrides); + } + + it('group that is not the tavern returns false, while tavern returns true', async () => { + for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) { + party.chat.push(generateTestMessage()); + } + expect(party.checkChatSpam(testUser)).to.eql(false); + + for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) { + tavern.chat.push(generateTestMessage()); + } + expect(tavern.checkChatSpam(testUser)).to.eql(true); + }); + + it('high enough contributor returns false', async () => { + let highContributor = testUser; + highContributor.contributor = { + level: SPAM_MIN_EXEMPT_CONTRIB_LEVEL, + }; + + for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) { + tavern.chat.push(generateTestMessage()); + } + + expect(tavern.checkChatSpam(highContributor)).to.eql(false); + }); + + it('chat with no messages returns false', async () => { + expect(tavern.chat.length).to.eql(0); + expect(tavern.checkChatSpam(testUser)).to.eql(false); + }); + + it('user has not reached limit but another one has returns false', async () => { + let otherUserID = '2'; + + for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) { + tavern.chat.push(generateTestMessage({uuid: otherUserID})); + } + + expect(tavern.checkChatSpam(testUser)).to.eql(false); + }); + + it('user messages is less than the limit returns false', async () => { + for (let i = 0; i < SPAM_MESSAGE_LIMIT - 1; i++) { + tavern.chat.push(generateTestMessage()); + } + + expect(tavern.checkChatSpam(testUser)).to.eql(false); + }); + + it('user has reached the message limit outside of window returns false', async () => { + for (let i = 0; i < SPAM_MESSAGE_LIMIT - 1; i++) { + tavern.chat.push(generateTestMessage()); + } + let earlierTimestamp = testTime - SPAM_WINDOW_LENGTH - 1; + tavern.chat.push(generateTestMessage({timestamp: earlierTimestamp})); + + expect(tavern.checkChatSpam(testUser)).to.eql(false); + }); + + it('user has posted too many messages in limit returns true', async () => { + for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) { + tavern.chat.push(generateTestMessage()); + } + + expect(tavern.checkChatSpam(testUser)).to.eql(true); + }); + }); + describe('#leaveGroup', () => { it('removes user from group quest', async () => { party.quest.members = { diff --git a/website/common/locales/en/messages.json b/website/common/locales/en/messages.json index 5a1c1b1bdb..1ed065d43f 100644 --- a/website/common/locales/en/messages.json +++ b/website/common/locales/en/messages.json @@ -56,6 +56,7 @@ "messageGroupChatFlagAlreadyReported": "You have already reported this message", "messageGroupChatNotFound": "Message not found!", "messageGroupChatAdminClearFlagCount": "Only an admin can clear the flag count!", + "messageGroupChatSpam": "Whoops, looks like you're posting too many messages! Please wait a minute and try again. The Tavern chat only holds 200 messages at a time, so Habitica encourages posting longer, more thoughtful messages and consolidating replies. Can't wait to hear what you have to say. :)", "messageUserOperationProtected": "path `<%= operation %>` was not saved, as it's a protected path.", "messageUserOperationNotFound": "<%= operation %> operation not found", diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index ed21bfe910..f68ecde053 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -153,6 +153,10 @@ api.postChat = { let lastClientMsg = req.query.previousMsg; chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + if (group.checkChatSpam(user)) { + throw new NotAuthorized(res.t('messageGroupChatSpam')); + } + let newChatMessage = group.sendChat(req.body.message, user); let toSave = [group.save()]; diff --git a/website/server/models/group.js b/website/server/models/group.js index 36b2090b09..f57ea96b7c 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -46,6 +46,16 @@ const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true'; const MAX_UPDATE_RETRIES = 5; +/* +# Spam constants to limit people from sending too many messages too quickly +# SPAM_MESSAGE_LIMIT - The amount of messages that can be sent in a time window +# SPAM_WINDOW_LENGTH - The window length for spam protection in milliseconds +# SPAM_MIN_EXEMPT_CONTRIB_LEVEL - Anyone at or above this level is exempt +*/ +export const SPAM_MESSAGE_LIMIT = 2; +export const SPAM_WINDOW_LENGTH = 60000; // 1 minute +export const SPAM_MIN_EXEMPT_CONTRIB_LEVEL = 4; + export let schema = new Schema({ name: {type: String, required: true}, description: String, @@ -1202,6 +1212,31 @@ schema.methods.removeTask = async function groupRemoveTask (task) { }, {multi: true}).exec(); }; +// Returns true if the user has reached the spam message limit +schema.methods.checkChatSpam = function groupCheckChatSpam (user) { + if (this._id !== TAVERN_ID) { + return false; + } else if (user.contributor && user.contributor.level >= SPAM_MIN_EXEMPT_CONTRIB_LEVEL) { + return false; + } + + let currentTime = Date.now(); + let userMessages = 0; + for (let i = 0; i < this.chat.length; i++) { + let message = this.chat[i]; + if (message.uuid === user._id && currentTime - message.timestamp <= SPAM_WINDOW_LENGTH) { + userMessages++; + if (userMessages >= SPAM_MESSAGE_LIMIT) { + return true; + } + } else if (currentTime - message.timestamp > SPAM_WINDOW_LENGTH) { + break; + } + } + + return false; +}; + schema.methods.isSubscribed = function isSubscribed () { let now = new Date(); let plan = this.purchased.plan;