diff --git a/config.json.example b/config.json.example index 04c1c535b4..3875097d6c 100644 --- a/config.json.example +++ b/config.json.example @@ -1,4 +1,5 @@ { + "ACCOUNT_MIN_CHAT_AGE": "0", "ADMIN_EMAIL": "you@example.com", "AMAZON_PAYMENTS_CLIENT_ID": "CLIENT_ID", "AMAZON_PAYMENTS_MODE": "sandbox", diff --git a/test/api/v3/integration/chat/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js index 0d53dfa999..7a9756e673 100644 --- a/test/api/v3/integration/chat/DELETE-chat_id.test.js +++ b/test/api/v3/integration/chat/DELETE-chat_id.test.js @@ -15,6 +15,10 @@ describe('DELETE /groups/:groupId/chat/:chatId', () => { type: 'guild', privacy: 'public', }, + leaderDetails: { + 'auth.timestamps.created': new Date('2022-01-01'), + balance: 10, + }, }); groupWithChat = group; diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index acff8b507b..909fbfa7a0 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -117,7 +117,9 @@ describe('POST /chat/:chatId/flag', () => { }); it('Flags a chat when the author\'s account was deleted', async () => { - const deletedUser = await generateUser(); + const deletedUser = await generateUser({ + 'auth.timestamps.created': new Date('2022-01-01'), + }); const { message } = await deletedUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE }); await deletedUser.del('/user', { password: 'password', diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 192f3e3aa7..beab851bb0 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -18,11 +18,16 @@ describe('POST /chat/:chatId/like', () => { privacy: 'public', }, members: 1, + leaderDetails: { + 'auth.timestamps.created': new Date('2022-01-01'), + balance: 10, + }, }); user = groupLeader; groupWithChat = group; anotherUser = members[0]; // eslint-disable-line prefer-destructuring + await anotherUser.update({ 'auth.timestamps.created': new Date('2022-01-01') }); }); it('Returns an error when chat message is not found', async () => { diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index 24283de89e..73ee343c79 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -38,10 +38,15 @@ describe('POST /chat', () => { members: 2, }); user = groupLeader; - await user.update({ 'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL }); // prevent tests accidentally throwing messageGroupChatSpam + await user.update({ + 'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL, + 'auth.timestamps.created': new Date('2022-01-01'), + }); // prevent tests accidentally throwing messageGroupChatSpam groupWithChat = group; member = members[0]; // eslint-disable-line prefer-destructuring additionalMember = members[1]; // eslint-disable-line prefer-destructuring + await member.update({ 'auth.timestamps.created': new Date('2022-01-01') }); + await additionalMember.update({ 'auth.timestamps.created': new Date('2022-01-01') }); }); it('Returns an error when no message is provided', async () => { @@ -104,7 +109,10 @@ describe('POST /chat', () => { }); const privateGuildMemberWithChatsRevoked = members[0]; - await privateGuildMemberWithChatsRevoked.update({ 'flags.chatRevoked': true }); + await privateGuildMemberWithChatsRevoked.update({ + 'flags.chatRevoked': true, + 'auth.timestamps.created': new Date('2022-01-01'), + }); const message = await privateGuildMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage }); @@ -122,7 +130,10 @@ describe('POST /chat', () => { }); const privatePartyMemberWithChatsRevoked = members[0]; - await privatePartyMemberWithChatsRevoked.update({ 'flags.chatRevoked': true }); + await privatePartyMemberWithChatsRevoked.update({ + 'flags.chatRevoked': true, + 'auth.timestamps.created': new Date('2022-01-01'), + }); const message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage }); @@ -183,7 +194,10 @@ describe('POST /chat', () => { }); const userWithChatShadowMuted = members[0]; - await userWithChatShadowMuted.update({ 'flags.chatShadowMuted': true }); + await userWithChatShadowMuted.update({ + 'flags.chatShadowMuted': true, + 'auth.timestamps.created': new Date('2022-01-01'), + }); const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage }); @@ -202,7 +216,10 @@ describe('POST /chat', () => { }); const userWithChatShadowMuted = members[0]; - await userWithChatShadowMuted.update({ 'flags.chatShadowMuted': true }); + await userWithChatShadowMuted.update({ + 'flags.chatShadowMuted': true, + 'auth.timestamps.created': new Date('2022-01-01'), + }); const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage }); @@ -312,6 +329,7 @@ describe('POST /chat', () => { }, members: 1, }); + await members[0].update({ 'auth.timestamps.created': new Date('2022-01-01') }); const message = await members[0].post(`/groups/${group._id}/chat`, { message: testBannedWordMessage }); @@ -330,6 +348,7 @@ describe('POST /chat', () => { // Update the bannedWordsAllowed property for the group group.update({ bannedWordsAllowed: true }); + await members[0].update({ 'auth.timestamps.created': new Date('2022-01-01') }); const message = await members[0].post(`/groups/${group._id}/chat`, { message: testBannedWordMessage }); @@ -345,6 +364,7 @@ describe('POST /chat', () => { }, members: 1, }); + await members[0].update({ 'auth.timestamps.created': new Date('2022-01-01') }); const message = await members[0].post(`/groups/${group._id}/chat`, { message: testBannedWordMessage }); @@ -411,6 +431,7 @@ describe('POST /chat', () => { }, members: 1, }); + await members[0].update({ 'auth.timestamps.created': new Date('2022-01-01') }); const message = await members[0].post(`/groups/${group._id}/chat`, { message: testSlurMessage }); @@ -430,6 +451,16 @@ describe('POST /chat', () => { }); }); + it('errors when user account is too young', async () => { + const brandNewUser = await generateUser(); + await expect(brandNewUser.post('/groups/habitrpg/chat', { message: 'hi im new' })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('chatTemporarilyUnavailable'), + }); + }); + it('creates a chat', async () => { const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`); @@ -492,6 +523,7 @@ describe('POST /chat', () => { 'items.currentMount': mount, 'items.currentPet': pet, 'preferences.style': style, + 'auth.timestamps.created': new Date('2022-01-01'), }); await userWithStyle.sync(); @@ -517,6 +549,7 @@ describe('POST /chat', () => { }; const backer = await generateUser({ backer: backerInfo, + 'auth.timestamps.created': new Date('2022-01-01'), }); const message = await backer.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); @@ -587,6 +620,9 @@ describe('POST /chat', () => { privacy: 'private', }, members: 1, + leaderDetails: { + 'auth.timestamps.created': new Date('2022-01-01'), + }, }); const message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage }); diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js index f64df56146..9cd2103bc6 100644 --- a/test/api/v3/integration/chat/POST-chat_seen.test.js +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -15,6 +15,10 @@ describe('POST /groups/:id/chat/seen', () => { privacy: 'public', }, members: 1, + leaderDetails: { + 'auth.timestamps.created': new Date('2022-01-01'), + balance: 10, + }, }); guild = group; @@ -51,6 +55,9 @@ describe('POST /groups/:id/chat/seen', () => { privacy: 'private', }, members: 1, + leaderDetails: { + 'auth.timestamps.created': new Date('2022-01-01'), + }, }); party = group; diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js index e9f77e5561..4bb99cc33f 100644 --- a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js +++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js @@ -18,6 +18,10 @@ describe('POST /groups/:id/chat/:id/clearflags', () => { type: 'guild', privacy: 'public', }, + leaderDetails: { + 'auth.timestamps.created': new Date('2022-01-01'), + balance: 10, + }, }); groupWithChat = group; @@ -65,6 +69,7 @@ describe('POST /groups/:id/chat/:id/clearflags', () => { members: 1, }); + await members[0].update({ 'auth.timestamps.created': new Date('2022-01-01') }); let privateMessage = await members[0].post(`/groups/${group._id}/chat`, { message: 'Some message' }); privateMessage = privateMessage.message; diff --git a/test/api/v3/integration/groups/POST-groups_groupId_leave.test.js b/test/api/v3/integration/groups/POST-groups_groupId_leave.test.js index 56de50b822..b7b16a7f04 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_leave.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_leave.test.js @@ -37,6 +37,7 @@ describe('POST /groups/:groupId/leave', () => { leader = groupLeader; member = members[0]; // eslint-disable-line prefer-destructuring memberCount = group.memberCount; + await members[0].update({ 'auth.timestamps.created': new Date('2022-01-01') }); }); it('prevents non members from leaving', async () => { @@ -152,6 +153,10 @@ describe('POST /groups/:groupId/leave', () => { type: 'guild', }, invites: 1, + leaderDetails: { + 'auth.timestamps.created': new Date('2022-01-01'), + balance: 10, + }, }); privateGuild = group; diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js index 7b7fc0e56e..c8a903d891 100644 --- a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -153,6 +153,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { }, invites: 1, members: 2, + leaderDetails: { 'auth.timestamps.created': new Date('2022-01-01') }, }); party = group; diff --git a/test/api/v3/integration/notifications/prevent-multiple-notification.js b/test/api/v3/integration/notifications/prevent-multiple-notification.js index cc4fd20a3d..31da8c1e16 100644 --- a/test/api/v3/integration/notifications/prevent-multiple-notification.js +++ b/test/api/v3/integration/notifications/prevent-multiple-notification.js @@ -25,6 +25,7 @@ describe('Prevent multiple notifications', () => { for (let i = 0; i < 4; i += 1) { for (let memberIndex = 0; memberIndex < partyMembers.length; memberIndex += 1) { + await partyMembers[memberIndex].update({ 'auth.timestamps.created': new Date('2022-01-01') }); // eslint-disable-line no-await-in-loop multipleChatMessages.push( partyMembers[memberIndex].post(`/groups/${party._id}/chat`, { message: `Message ${i}_${memberIndex}` }), ); diff --git a/test/helpers/start-server.js b/test/helpers/start-server.js index 8830224f70..570f53461f 100644 --- a/test/helpers/start-server.js +++ b/test/helpers/start-server.js @@ -11,6 +11,7 @@ if (process.env.LOAD_SERVER === '0') { // when the server is in a different proc setupNconf('./config.json.example'); nconf.set('NODE_DB_URI', nconf.get('TEST_DB_URI')); nconf.set('NODE_ENV', 'test'); + nconf.set('ACCOUNT_MIN_CHAT_AGE', '2'); nconf.set('IS_TEST', true); // We require src/server and not src/index because // 1. nconf is already setup diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index e5186a08f8..e6c0f943cc 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -364,5 +364,6 @@ "managerNotes": "Manager's Notes", "assignedDateOnly": "Assigned on <%= date %>", "assignedDateAndUser": "Assigned by @<%- username %> on <%= date %>", - "claimRewards": "Claim Rewards" + "claimRewards": "Claim Rewards", + "chatTemporarilyUnavailable": "Chat is temporarily unavailable. Please try again later." } diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index c19a16e5be..72df0f719d 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -1,3 +1,4 @@ +import moment from 'moment'; import nconf from 'nconf'; import { authWithHeaders } from '../../middlewares/auth'; import { model as Group } from '../../models/group'; @@ -22,7 +23,11 @@ import { getMatchesByWordArray } from '../../libs/stringUtils'; import bannedSlurs from '../../libs/bannedSlurs'; import apiError from '../../libs/apiError'; import highlightMentions from '../../libs/highlightMentions'; +import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService'; +const analytics = getAnalyticsServiceByEnvironment(); + +const ACCOUNT_MIN_CHAT_AGE = Number(nconf.get('ACCOUNT_MIN_CHAT_AGE')); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map(email => ({ email, canSend: true })); /** @@ -188,6 +193,17 @@ api.postChat = { throw new NotAuthorized(res.t('messageGroupChatSpam')); } + // Check if account is newer than the minimum age for chat participation + if (moment().diff(user.auth.timestamps.created, 'minutes') < ACCOUNT_MIN_CHAT_AGE) { + analytics.track('chat age error', { + uuid: user._id, + hitType: 'event', + category: 'behavior', + headers: req.headers, + }); + throw new BadRequest(res.t('chatTemporarilyUnavailable')); + } + const sanitizedMessageText = sanitizeMessageText(req.body.message); const [message, mentions, mentionedMembers] = await highlightMentions(sanitizedMessageText); let client = req.headers['x-client'] || '3rd Party';