Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions BackendAcademy/src/chat/chat-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Session-based chat rate limiting.
*
* Tracks message timestamps per session (keyed by senderId) using a sliding
* window so a single user cannot flood chat rooms. This is scoped to the chat
* module and is independent of the global ThrottlerGuard, which limits by IP.
*/
export interface ChatRateLimitConfig {
/** Maximum number of messages allowed within the window. */
maxMessages: number;
/** Length of the sliding window in milliseconds. */
windowMs: number;
}

export const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimitConfig = {
maxMessages: 10,
windowMs: 10_000,
};

export class ChatRateLimiter {
private readonly hits = new Map<string, number[]>();

constructor(
private readonly config: ChatRateLimitConfig = DEFAULT_CHAT_RATE_LIMIT,
) {}

/**
* Records an attempt for the given session and reports whether it is allowed.
* Returns the number of seconds to wait when the limit has been exceeded.
*/
check(sessionId: string, now: number = Date.now()): {
allowed: boolean;
retryAfterSeconds: number;
} {
const windowStart = now - this.config.windowMs;
const recent = (this.hits.get(sessionId) ?? []).filter(
(timestamp) => timestamp > windowStart,
);

if (recent.length >= this.config.maxMessages) {
this.hits.set(sessionId, recent);
const oldest = recent[0];
const retryAfterMs = oldest + this.config.windowMs - now;
return {
allowed: false,
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
};
}

recent.push(now);
this.hits.set(sessionId, recent);
return { allowed: true, retryAfterSeconds: 0 };
}

/** Clears all tracked sessions (primarily for testing). */
reset(): void {
this.hits.clear();
}
}
86 changes: 86 additions & 0 deletions BackendAcademy/src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,90 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { ChatService } from './chat.service';
import {
ChatRateLimiter,
DEFAULT_CHAT_RATE_LIMIT,
} from './chat-rate-limit';

describe('ChatRateLimiter', () => {
it('allows messages up to the configured limit then blocks', () => {
const limiter = new ChatRateLimiter({ maxMessages: 3, windowMs: 1000 });
const now = 1_000_000;

expect(limiter.check('session-1', now).allowed).toBe(true);
expect(limiter.check('session-1', now).allowed).toBe(true);
expect(limiter.check('session-1', now).allowed).toBe(true);

const blocked = limiter.check('session-1', now);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
});

it('tracks each session independently', () => {
const limiter = new ChatRateLimiter({ maxMessages: 1, windowMs: 1000 });
const now = 1_000_000;

expect(limiter.check('session-a', now).allowed).toBe(true);
expect(limiter.check('session-a', now).allowed).toBe(false);
// A different session is unaffected by session-a's usage.
expect(limiter.check('session-b', now).allowed).toBe(true);
});

it('allows messages again once the window has elapsed', () => {
const limiter = new ChatRateLimiter({ maxMessages: 1, windowMs: 1000 });
const start = 1_000_000;

expect(limiter.check('session-1', start).allowed).toBe(true);
expect(limiter.check('session-1', start).allowed).toBe(false);
// Past the window the slate is clean.
expect(limiter.check('session-1', start + 1001).allowed).toBe(true);
});
});

describe('ChatService rate limiting', () => {
it('rejects messages once a session exceeds the limit', () => {
const service = new ChatService();
const send = () =>
service.createMessage({
roomId: 'room-1',
senderId: 'spammer',
content: 'hi',
});

for (let i = 0; i < DEFAULT_CHAT_RATE_LIMIT.maxMessages; i++) {
expect(() => send()).not.toThrow();
}

expect(() => send()).toThrow(HttpException);
try {
send();
} catch (err) {
expect((err as HttpException).getStatus()).toBe(
HttpStatus.TOO_MANY_REQUESTS,
);
}
});

it('shares the limit across createMessage and shareCodeSnippet', () => {
const service = new ChatService();
for (let i = 0; i < DEFAULT_CHAT_RATE_LIMIT.maxMessages; i++) {
service.createMessage({
roomId: 'room-1',
senderId: 'user-1',
content: 'hi',
});
}

expect(() =>
service.shareCodeSnippet({
roomId: 'room-1',
senderId: 'user-1',
content: 'snippet',
code: 'fn main() {}',
language: 'rust',
}),
).toThrow(HttpException);
});
});

describe('ChatService code snippet sharing', () => {
it('creates a shared code snippet message with metadata', () => {
Expand Down
20 changes: 19 additions & 1 deletion BackendAcademy/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Injectable } from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ChatRoom, Message } from './interfaces/chat.interface';
import { CreateMessageDto } from './dto/create-message.dto';
import { CreateRoomDto } from './dto/create-room.dto';
import { ShareCodeSnippetDto } from './dto/share-code-snippet.dto';
import { ChatRateLimiter } from './chat-rate-limit';

@Injectable()
export class ChatService {
private rooms: ChatRoom[] = [];
private messages: Message[] = [];
private readonly rateLimiter = new ChatRateLimiter();

createRoom(createRoomDto: CreateRoomDto): ChatRoom {
const newRoom: ChatRoom = {
Expand All @@ -28,6 +30,7 @@ export class ChatService {
}

createMessage(createMessageDto: CreateMessageDto): Message {
this.enforceRateLimit(createMessageDto.senderId);
const newMessage: Message = {
id: Math.random().toString(36).substring(2, 9),
...createMessageDto,
Expand All @@ -38,6 +41,7 @@ export class ChatService {
}

shareCodeSnippet(shareCodeSnippetDto: ShareCodeSnippetDto): Message {
this.enforceRateLimit(shareCodeSnippetDto.senderId);
const newMessage: Message = {
id: Math.random().toString(36).substring(2, 9),
...shareCodeSnippetDto,
Expand All @@ -56,4 +60,18 @@ export class ChatService {
findMessagesByRoom(roomId: string): Message[] {
return this.messages.filter((m) => m.roomId === roomId);
}

private enforceRateLimit(senderId: string): void {
const { allowed, retryAfterSeconds } = this.rateLimiter.check(senderId);
if (!allowed) {
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Chat rate limit exceeded. Please slow down.',
retryAfter: retryAfterSeconds,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
}
Loading