diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index d34a69f912..9fb625edba 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -10,11 +10,17 @@ import { TAVERN_ID, } from '../../../../../website/server/models/group'; import { v4 as generateUUID } from 'uuid'; +import * as email from '../../../../../website/server/libs/email'; +import { IncomingWebhook } from '@slack/client'; +import nconf from 'nconf'; + +const BASE_URL = nconf.get('BASE_URL'); describe('POST /chat', () => { let user, groupWithChat, member, additionalMember; let testMessage = 'Test Message'; let testBannedWordMessage = 'TEST_PLACEHOLDER_SWEAR_WORD_HERE'; + let testSlurMessage = 'message with TEST_PLACEHOLDER_SLUR_WORD_HERE'; before(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ @@ -166,6 +172,114 @@ describe('POST /chat', () => { }); }); + context('banned slur', () => { + beforeEach(() => { + sandbox.spy(email, 'sendTxn'); + sandbox.stub(IncomingWebhook.prototype, 'send'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('errors and revokes privileges when chat message contains a banned slur', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: testSlurMessage})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('bannedSlurUsed'), + }); + + // Email sent to mods + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; + expect(email.sendTxn.args[0][1]).to.be.eql('slur-report-to-mods'); + + // Slack message to mods + expect(IncomingWebhook.prototype.send).to.be.calledOnce; + /* eslint-disable camelcase */ + expect(IncomingWebhook.prototype.send).to.be.calledWith({ + text: `${user.profile.name} (${user.id}) tried to post a slur`, + attachments: [{ + fallback: 'Slur Message', + color: 'danger', + author_name: `${user.profile.name} - ${user.auth.local.email} - ${user._id}`, + title: 'Slur in Test Guild', + title_link: `${BASE_URL}/#/options/groups/guilds/${groupWithChat.id}`, + text: testSlurMessage, + // footer: sandbox.match(/<.*?groupId=group-id&chatId=chat-id\|Flag this message>/), + mrkdwn_in: [ + 'text', + ], + }], + }); + /* eslint-enable camelcase */ + + // Chat privileges are revoked + await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('chatPrivilegesRevoked'), + }); + + // Restore chat privileges to continue testing + user.flags.chatRevoked = false; + await user.update({'flags.chatRevoked': false}); + }); + + it('does not allow slurs in private groups', async () => { + let { group, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Party', + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + await expect(members[0].post(`/groups/${group._id}/chat`, { message: testSlurMessage})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('bannedSlurUsed'), + }); + + // Email sent to mods + await sleep(0.5); + expect(email.sendTxn).to.be.calledThrice; + expect(email.sendTxn.args[2][1]).to.be.eql('slur-report-to-mods'); + + // Slack message to mods + expect(IncomingWebhook.prototype.send).to.be.calledOnce; + /* eslint-disable camelcase */ + expect(IncomingWebhook.prototype.send).to.be.calledWith({ + text: `${members[0].profile.name} (${members[0].id}) tried to post a slur`, + attachments: [{ + fallback: 'Slur Message', + color: 'danger', + author_name: `${members[0].profile.name} - ${members[0].auth.local.email} - ${members[0]._id}`, + title: 'Slur in Party - (private party)', + title_link: undefined, + text: testSlurMessage, + // footer: sandbox.match(/<.*?groupId=group-id&chatId=chat-id\|Flag this message>/), + mrkdwn_in: [ + 'text', + ], + }], + }); + /* eslint-enable camelcase */ + + // Chat privileges are revoked + await expect(members[0].post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('chatPrivilegesRevoked'), + }); + + // Restore chat privileges to continue testing + members[0].flags.chatRevoked = false; + await members[0].update({'flags.chatRevoked': false}); + }); + }); + it('does not error when sending a message to a private guild with a user with revoked chat', async () => { let { group, members } = await createAndPopulateGroup({ groupDetails: { diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 4ea8eb2c1e..2d6ee1c816 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -22,6 +22,7 @@ "communityGuidelinesRead1": "Please read our", "communityGuidelinesRead2": "before chatting.", "bannedWordUsed": "Oops! Looks like this post contains a swearword, religious oath, or reference to an addictive substance or adult topic. Habitica has users from all backgrounds, so we keep our chat very clean. Feel free to edit your message so you can post it!", + "bannedSlurUsed": "Your post contained inappropriate language, and your chat privileges have been revoked.", "party": "Party", "createAParty": "Create A Party", "updatedParty": "Party settings updated.", diff --git a/website/server/bannedSlurs.js b/website/server/bannedSlurs.js new file mode 100644 index 0000000000..2afcd1ad2b --- /dev/null +++ b/website/server/bannedSlurs.js @@ -0,0 +1,56 @@ +/* eslint-disable no-multiple-empty-lines */ + +// CONTENT WARNING: +// This file contains slurs, swear words, religious oaths, and words related to addictive substance and adult topics. +// Do not read this file if you do not want to be exposed to those words. +// The words are stored in an array called `bannedSlurs` which is then exported with `module.exports = bannedSlurs;` +// This file does not contain any other code. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +let bannedSlurs = ['TEST_PLACEHOLDER_SLUR_WORD_HERE']; + +module.exports = bannedSlurs; diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index 58c772da5e..23ef1ad642 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -15,6 +15,7 @@ import nconf from 'nconf'; import Bluebird from 'bluebird'; import bannedWords from '../../libs/bannedWords'; import { TAVERN_ID } from '../../models/group'; +import bannedSlurs from '../../bannedSlurs'; const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; @@ -53,6 +54,44 @@ async function getAuthorEmailFromMessage (message) { } } +// @TODO: Probably move this to a library +function matchExact (r, str) { + let match = str.match(r); + return match !== null && match[0] !== null; +} + +let bannedWordRegexs = []; +for (let i = 0; i < bannedWords.length; i += 1) { + let word = bannedWords[i]; + let regEx = new RegExp(`\\b([^a-z]+)?${word.toLowerCase()}([^a-z]+)?\\b`); + bannedWordRegexs.push(regEx); +} + +function textContainsBannedWords (message) { + for (let i = 0; i < bannedWordRegexs.length; i += 1) { + let regEx = bannedWordRegexs[i]; + if (matchExact(regEx, message.toLowerCase())) return true; + } + + return false; +} + +let bannedSlurRegexs = []; +for (let i = 0; i < bannedSlurs.length; i += 1) { + let word = bannedSlurs[i]; + let regEx = new RegExp(`\\b([^a-z]+)?${word.toLowerCase()}([^a-z]+)?\\b`); + bannedSlurRegexs.push(regEx); +} + +function textContainsBannedSlur (message) { + for (let i = 0; i < bannedSlurRegexs.length; i += 1) { + let regEx = bannedSlurRegexs[i]; + if (matchExact(regEx, message.toLowerCase())) return true; + } + + return false; +} + /** * @api {get} /api/v3/groups/:groupId/chat Get chat messages from a group * @apiName GetChat @@ -85,28 +124,6 @@ api.getChat = { }, }; -// @TODO: Probably move this to a library -function matchExact (r, str) { - let match = str.match(r); - return match !== null && match[0] !== null; -} - -let bannedWordRegexs = []; -for (let i = 0; i < bannedWords.length; i += 1) { - let word = bannedWords[i]; - let regEx = new RegExp(`\\b([^a-z]+)?${word.toLowerCase()}([^a-z]+)?\\b`); - bannedWordRegexs.push(regEx); -} - -function textContainsBannedWords (message) { - for (let i = 0; i < bannedWordRegexs.length; i += 1) { - let regEx = bannedWordRegexs[i]; - if (matchExact(regEx, message.toLowerCase())) return true; - } - - return false; -} - /** * @api {post} /api/v3/groups/:groupId/chat Post chat message to a group * @apiName PostChat @@ -139,6 +156,44 @@ api.postChat = { let group = await Group.getGroup({user, groupId}); + // Check message for banned slurs + if (textContainsBannedSlur(req.body.message)) { + let message = req.body.message; + user.flags.chatRevoked = true; + await user.save(); + + // Email the mods + let authorEmail = getUserInfo(user, ['email']).email; + let groupUrl = getGroupUrl(group); + + let report = [ + {name: 'MESSAGE_TIME', content: (new Date()).toString()}, + {name: 'MESSAGE_TEXT', content: message}, + + {name: 'AUTHOR_USERNAME', content: user.profile.name}, + {name: 'AUTHOR_UUID', content: user._id}, + {name: 'AUTHOR_EMAIL', content: authorEmail}, + {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${user._id}`}, + + {name: 'GROUP_NAME', content: group.name}, + {name: 'GROUP_TYPE', content: group.type}, + {name: 'GROUP_ID', content: group._id}, + {name: 'GROUP_URL', content: groupUrl}, + ]; + + sendTxn(FLAG_REPORT_EMAILS, 'slur-report-to-mods', report); + + // Slack the mods + slack.sendSlurNotification({ + authorEmail, + author: user, + group, + message, + }); + + throw new BadRequest(res.t('bannedSlurUsed')); + } + if (!group) throw new NotFound(res.t('groupNotFound')); if (group.privacy !== 'private' && user.flags.chatRevoked) { throw new NotAuthorized(res.t('chatPrivilegesRevoked')); diff --git a/website/server/libs/slack.js b/website/server/libs/slack.js index dd60f84a2c..799f97a5bd 100644 --- a/website/server/libs/slack.js +++ b/website/server/libs/slack.js @@ -93,3 +93,49 @@ module.exports = { sendFlagNotification, sendSubscriptionNotification, }; + +function sendSlurNotification ({ + authorEmail, + author, + group, + message, +}) { + if (!SLACK_FLAGGING_URL) { + return; + } + let titleLink; + let authorName; + let title = `Slur in ${group.name}`; + let text = `${author.profile.name} (${author._id}) tried to post a slur`; + + if (group.id === TAVERN_ID) { + titleLink = `${BASE_URL}/#/options/groups/tavern`; + } else if (group.privacy === 'public') { + titleLink = `${BASE_URL}/#/options/groups/guilds/${group.id}`; + } else { + title += ` - (${group.privacy} ${group.type})`; + } + + authorName = `${author.profile.name} - ${authorEmail} - ${author.id}`; + + flagSlack.send({ + text, + attachments: [{ + fallback: 'Slur Message', + color: 'danger', + author_name: authorName, + title, + title_link: titleLink, + text: message, + // What to replace the footer with? + // footer: `<${SLACK_FLAGGING_FOOTER_LINK}?groupId=${group.id}&chatId=${message.id}|Flag this message>`, + mrkdwn_in: [ + 'text', + ], + }], + }); +} + +module.exports = { + sendFlagNotification, sendSubscriptionNotification, sendSlurNotification, +}; diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 6aa32a3a54..ab311c2dad 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -1,11 +1,13 @@ import moment from 'moment'; import common from '../../../common'; + import Bluebird from 'bluebird'; import { chatDefaults, TAVERN_ID, model as Group, } from '../group'; + import { defaults, map, flatten, flow, compact, uniq, partialRight } from 'lodash'; import { model as UserNotification } from '../userNotification'; import schema from './schema';