mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 21:57:22 +01:00
* Adding code to look over the most recent messages to look for spam from a user * Adding in translatable error message * Adding 2 tests for spam detection * Fixing changes requested for pull request * Adding unit tests for group and fixing requested changes * Fixing message and tests * Forgot to remove this import * Fixing lint errors * Cleaning up the code and tests to be more readable * Fixing lint errors * Fixed linting issues * Syntax fixes * Updated grammar
This commit is contained in:
@@ -4,10 +4,15 @@ import {
|
|||||||
sleep,
|
sleep,
|
||||||
server,
|
server,
|
||||||
} from '../../../../helpers/api-v3-integration.helper';
|
} 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';
|
import { v4 as generateUUID } from 'uuid';
|
||||||
|
|
||||||
describe('POST /chat', () => {
|
describe('POST /chat', () => {
|
||||||
let user, groupWithChat, userWithChatRevoked, member;
|
let user, groupWithChat, member, additionalMember;
|
||||||
let testMessage = 'Test Message';
|
let testMessage = 'Test Message';
|
||||||
let testBannedWordMessage = 'TEST_PLACEHOLDER_SWEAR_WORD_HERE';
|
let testBannedWordMessage = 'TEST_PLACEHOLDER_SWEAR_WORD_HERE';
|
||||||
|
|
||||||
@@ -23,8 +28,8 @@ describe('POST /chat', () => {
|
|||||||
|
|
||||||
user = groupLeader;
|
user = groupLeader;
|
||||||
groupWithChat = group;
|
groupWithChat = group;
|
||||||
userWithChatRevoked = await members[0].update({'flags.chatRevoked': true});
|
|
||||||
member = members[0];
|
member = members[0];
|
||||||
|
additionalMember = members[1];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Returns an error when no message is provided', async () => {
|
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 () => {
|
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({
|
await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({
|
||||||
code: 404,
|
code: 404,
|
||||||
error: 'NotFound',
|
error: 'NotFound',
|
||||||
@@ -264,4 +270,30 @@ describe('POST /chat', () => {
|
|||||||
expect(message.message.id).to.exist;
|
expect(message.message.id).to.exist;
|
||||||
expect(memberWithNotification.newMessages[`${group._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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import moment from 'moment';
|
|||||||
import { v4 as generateUUID } from 'uuid';
|
import { v4 as generateUUID } from 'uuid';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import { sleep } from '../../../../helpers/api-unit.helper';
|
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 { model as User } from '../../../../../website/server/models/user';
|
||||||
import { quests as questScrolls } from '../../../../../website/common/script/content';
|
import { quests as questScrolls } from '../../../../../website/common/script/content';
|
||||||
import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook';
|
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', () => {
|
describe('#leaveGroup', () => {
|
||||||
it('removes user from group quest', async () => {
|
it('removes user from group quest', async () => {
|
||||||
party.quest.members = {
|
party.quest.members = {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"messageGroupChatFlagAlreadyReported": "You have already reported this message",
|
"messageGroupChatFlagAlreadyReported": "You have already reported this message",
|
||||||
"messageGroupChatNotFound": "Message not found!",
|
"messageGroupChatNotFound": "Message not found!",
|
||||||
"messageGroupChatAdminClearFlagCount": "Only an admin can clear the flag count!",
|
"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.",
|
"messageUserOperationProtected": "path `<%= operation %>` was not saved, as it's a protected path.",
|
||||||
"messageUserOperationNotFound": "<%= operation %> operation not found",
|
"messageUserOperationNotFound": "<%= operation %> operation not found",
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ api.postChat = {
|
|||||||
let lastClientMsg = req.query.previousMsg;
|
let lastClientMsg = req.query.previousMsg;
|
||||||
chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
|
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 newChatMessage = group.sendChat(req.body.message, user);
|
||||||
|
|
||||||
let toSave = [group.save()];
|
let toSave = [group.save()];
|
||||||
|
|||||||
@@ -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 CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
|
||||||
const MAX_UPDATE_RETRIES = 5;
|
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({
|
export let schema = new Schema({
|
||||||
name: {type: String, required: true},
|
name: {type: String, required: true},
|
||||||
description: String,
|
description: String,
|
||||||
@@ -1202,6 +1212,31 @@ schema.methods.removeTask = async function groupRemoveTask (task) {
|
|||||||
}, {multi: true}).exec();
|
}, {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 () {
|
schema.methods.isSubscribed = function isSubscribed () {
|
||||||
let now = new Date();
|
let now = new Date();
|
||||||
let plan = this.purchased.plan;
|
let plan = this.purchased.plan;
|
||||||
|
|||||||
Reference in New Issue
Block a user