Skip to content

Commit 1148df1

Browse files
committed
Add spam guard to limit daily new conversations and auto-ban violators
1 parent 414a86e commit 1148df1

3 files changed

Lines changed: 121 additions & 3 deletions

File tree

backend/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@compass/api",
3-
"version": "1.45.3",
3+
"version": "1.46.0",
44
"private": true,
55
"description": "Backend API endpoints",
66
"main": "src/serve.ts",

backend/api/src/create-private-user-message-channel.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import {getConnectionInterests} from 'api/get-connection-interests'
22
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
33
import {addUsersToPrivateMessageChannel} from 'api/helpers/private-messages'
4+
import {sendDiscordMessage} from 'common/discord/core'
5+
import {DOMAIN} from 'common/envs/constants'
46
import {filterDefined} from 'common/util/array'
57
import * as admin from 'firebase-admin'
68
import {uniq} from 'lodash'
79
import {getProfile} from 'shared/profiles/supabase'
810
import {createSupabaseDirectClient} from 'shared/supabase/init'
11+
import {updateUser} from 'shared/supabase/users'
912
import {getPrivateUser, getUser} from 'shared/utils'
1013

14+
// Max number of new conversations a user may start within a rolling 24h window.
15+
// Creating one more than this auto-bans them for suspected spam.
16+
const MAX_NEW_CHANNELS_PER_DAY = 5
17+
1118
export const createPrivateUserMessageChannel: APIHandler<
1219
'create-private-user-message-channel'
1320
> = async (body, auth) => {
@@ -73,6 +80,35 @@ export const createPrivateUserMessageChannel: APIHandler<
7380
channelId: Number(currentChannel.channel_id),
7481
}
7582

83+
// Spam guard: count how many conversations this user has started in the last 24h.
84+
// If they've already created MAX_NEW_CHANNELS_PER_DAY, this new one is over the
85+
// limit — ban them right away and flag it to the admins for review.
86+
const {count: recentChannelCount} = await pg.one(
87+
`select count(*) as count
88+
from private_user_message_channel_members m
89+
join private_user_message_channels c on c.id = m.channel_id
90+
where m.user_id = $1
91+
and m.role = 'creator'
92+
and c.created_time > now() - interval '24 hours'`,
93+
[creatorId],
94+
)
95+
96+
if (Number(recentChannelCount) >= MAX_NEW_CHANNELS_PER_DAY) {
97+
await updateUser(creatorId, {isBannedFromPosting: true})
98+
try {
99+
const message = `
100+
🔨 **Auto-ban: conversation spam** 🔨
101+
**User:** ${creator.name} ([@${creator.username}](https://${DOMAIN}/${creator.username}))
102+
**Reason:** Started ${Number(recentChannelCount) + 1} conversations within 24h (limit ${MAX_NEW_CHANNELS_PER_DAY}).
103+
Please review.
104+
`
105+
await sendDiscordMessage(message, 'reports')
106+
} catch (e) {
107+
console.error('Failed to send auto-ban discord report', e)
108+
}
109+
throw APIErrors.forbidden('You are banned')
110+
}
111+
76112
const channel = await pg.one(
77113
`insert into private_user_message_channels default
78114
values

backend/api/tests/unit/create-private-user-message-channel.unit.test.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import {createPrivateUserMessageChannel} from 'api/create-private-user-message-channel'
22
import {AuthedUser} from 'api/helpers/endpoint'
33
import * as privateMessageModules from 'api/helpers/private-messages'
4+
import {sendDiscordMessage} from 'common/discord/core'
45
import {sqlMatch} from 'common/test-utils'
56
import * as utilArrayModules from 'common/util/array'
67
import * as admin from 'firebase-admin'
78
import * as supabaseInit from 'shared/supabase/init'
9+
import {updateUser} from 'shared/supabase/users'
810
import * as sharedUtils from 'shared/utils'
911

1012
jest.mock('shared/supabase/init')
1113
jest.mock('common/util/array')
14+
jest.mock('common/discord/core')
1215
jest.mock('api/helpers/private-messages')
16+
jest.mock('shared/supabase/users')
1317
jest.mock('shared/utils')
1418
jest.mock('firebase-admin', () => ({
1519
auth: jest.fn(),
@@ -110,7 +114,10 @@ describe('createPrivateUserMessageChannel', () => {
110114
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)
111115
;(utilArrayModules.filterDefined as jest.Mock).mockReturnValue(mockPrivateUsers)
112116
;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(false)
113-
;(mockPg.one as jest.Mock).mockResolvedValue(mockChannel)
117+
// First pg.one is the recent-channel count (under limit), second is the channel insert.
118+
;(mockPg.one as jest.Mock)
119+
.mockResolvedValueOnce({count: '0'})
120+
.mockResolvedValueOnce(mockChannel)
114121

115122
const results = await createPrivateUserMessageChannel(mockBody, mockAuth, mockReq)
116123

@@ -121,7 +128,9 @@ describe('createPrivateUserMessageChannel', () => {
121128
expect(sharedUtils.getPrivateUser).toBeCalledTimes(2)
122129
expect(sharedUtils.getPrivateUser).toBeCalledWith(mockUserIds[0])
123130
expect(sharedUtils.getPrivateUser).toBeCalledWith(mockUserIds[1])
124-
expect(mockPg.one).toBeCalledTimes(1)
131+
expect(updateUser).not.toHaveBeenCalled()
132+
expect(sendDiscordMessage).not.toHaveBeenCalled()
133+
expect(mockPg.one).toBeCalledTimes(2)
125134
expect(mockPg.one).toBeCalledWith(
126135
sqlMatch(
127136
'insert into private_user_message_channels default\n values\n returning id',
@@ -143,6 +152,79 @@ describe('createPrivateUserMessageChannel', () => {
143152
})
144153
})
145154

155+
describe('when the daily new-conversation limit is exceeded', () => {
156+
const mockBody = {userIds: ['123']}
157+
const mockAuth = {uid: '321'} as AuthedUser
158+
const mockReq = {} as any
159+
const mockPrivateUsers = [
160+
{id: '123', blockedUserIds: [], blockedByUserIds: []},
161+
{id: '321', blockedUserIds: [], blockedByUserIds: []},
162+
]
163+
const mockCreator = {
164+
id: '321',
165+
name: 'Spammy McSpam',
166+
username: 'spammy',
167+
isBannedFromPosting: false,
168+
}
169+
170+
beforeEach(() => {
171+
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)
172+
;(utilArrayModules.filterDefined as jest.Mock).mockReturnValue(mockPrivateUsers)
173+
// getProfile + no-existing-channel lookups both return falsy.
174+
;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(null)
175+
})
176+
177+
it('bans the user and notifies admins on the 6th conversation within 24h', async () => {
178+
// Already created 5 channels in the last 24h → this new one is over the limit.
179+
;(mockPg.one as jest.Mock).mockResolvedValueOnce({count: '5'})
180+
;(sendDiscordMessage as jest.Mock).mockResolvedValue(null)
181+
182+
await expect(
183+
createPrivateUserMessageChannel(mockBody, mockAuth, mockReq),
184+
).rejects.toThrowError('You are banned')
185+
186+
expect(updateUser).toHaveBeenCalledWith(mockAuth.uid, {isBannedFromPosting: true})
187+
expect(sendDiscordMessage).toHaveBeenCalledTimes(1)
188+
expect(sendDiscordMessage).toHaveBeenCalledWith(
189+
expect.stringContaining('Auto-ban'),
190+
'reports',
191+
)
192+
// The count query runs, but the channel is never created once the user is banned.
193+
expect(mockPg.one).toHaveBeenCalledTimes(1)
194+
expect(privateMessageModules.addUsersToPrivateMessageChannel).not.toHaveBeenCalled()
195+
})
196+
197+
it('still bans when the Discord notification fails', async () => {
198+
;(mockPg.one as jest.Mock).mockResolvedValueOnce({count: '5'})
199+
;(sendDiscordMessage as jest.Mock).mockRejectedValue(new Error('Discord down'))
200+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
201+
202+
await expect(
203+
createPrivateUserMessageChannel(mockBody, mockAuth, mockReq),
204+
).rejects.toThrowError('You are banned')
205+
206+
expect(updateUser).toHaveBeenCalledWith(mockAuth.uid, {isBannedFromPosting: true})
207+
expect(errorSpy).toHaveBeenCalledWith(
208+
expect.stringContaining('Failed to send auto-ban discord report'),
209+
expect.any(Error),
210+
)
211+
})
212+
213+
it('does not ban when still under the limit', async () => {
214+
// 4 channels so far → this 5th one is allowed; second pg.one is the channel insert.
215+
;(mockPg.one as jest.Mock)
216+
.mockResolvedValueOnce({count: '4'})
217+
.mockResolvedValueOnce({id: '333'})
218+
219+
const results = await createPrivateUserMessageChannel(mockBody, mockAuth, mockReq)
220+
221+
expect(results.status).toBe('success')
222+
expect(updateUser).not.toHaveBeenCalled()
223+
expect(sendDiscordMessage).not.toHaveBeenCalled()
224+
expect(privateMessageModules.addUsersToPrivateMessageChannel).toHaveBeenCalledTimes(1)
225+
})
226+
})
227+
146228
describe('when an error occurs', () => {
147229
it('should throw if user email is not verified', async () => {
148230
const mockBody = {

0 commit comments

Comments
 (0)