diff --git a/backend/src/common/decorators/rate-limit.decorator.ts b/backend/src/common/decorators/rate-limit.decorator.ts new file mode 100644 index 00000000..600ca363 --- /dev/null +++ b/backend/src/common/decorators/rate-limit.decorator.ts @@ -0,0 +1,23 @@ +import { SetMetadata } from '@nestjs/common' + +export const RATE_LIMIT_KEY = 'rateLimit' + +export interface RateLimitOptions { + /** Maximum number of requests allowed within the time window */ + limit: number + /** Time window in milliseconds */ + windowMs: number + /** Optional key function to extract the client identifier (defaults to IP) */ + keyPrefix?: string +} + +/** + * Apply a rate limit to a route handler. + * + * Usage: + * @RateLimit({ limit: 5, windowMs: 60_000 }) + * @Post('join') + * async joinQueue() { ... } + */ +export const RateLimit = (options: RateLimitOptions) => + SetMetadata(RATE_LIMIT_KEY, options) diff --git a/backend/src/common/decorators/ws-rate-limit.decorator.ts b/backend/src/common/decorators/ws-rate-limit.decorator.ts new file mode 100644 index 00000000..7fb5fe31 --- /dev/null +++ b/backend/src/common/decorators/ws-rate-limit.decorator.ts @@ -0,0 +1,21 @@ +import type { WsRateLimitOptions } from '../guards/ws-rate-limit.guard' + +const WS_RATE_LIMIT_KEY = 'wsRateLimit' + +/** + * Apply a per-handler WebSocket rate limit. + * + * Usage: + * @WsRateLimit({ limit: 10, windowMs: 60_000 }) + * @SubscribeMessage('joinQueue') + * handleJoinQueue(@MessageBody() data) { ... } + */ +export const WsRateLimit = (options: WsRateLimitOptions) => + (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => { + if (propertyKey && descriptor) { + Reflect.defineMetadata(WS_RATE_LIMIT_KEY, options, descriptor.value) + } else { + Reflect.defineMetadata(WS_RATE_LIMIT_KEY, options, target) + } + return descriptor ?? target + } diff --git a/backend/src/common/guards/rate-limit.guard.ts b/backend/src/common/guards/rate-limit.guard.ts new file mode 100644 index 00000000..2f5edb33 --- /dev/null +++ b/backend/src/common/guards/rate-limit.guard.ts @@ -0,0 +1,115 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { RATE_LIMIT_KEY, RateLimitOptions } from '../decorators/rate-limit.decorator' + +interface RequestRecord { + count: number + resetTime: number +} + +@Injectable() +export class RateLimitGuard implements CanActivate { + private readonly logger = new Logger(RateLimitGuard.name) + + /** Per-key request records keyed by `${routeKey}::${clientKey}` */ + private readonly hits = new Map() + + /** Periodic cleanup timer (10 minute interval) */ + private readonly cleanupInterval: ReturnType + + constructor(private readonly reflector: Reflector) { + this.cleanupInterval = setInterval(() => this.cleanup(), 10 * 60 * 1000) + } + + canActivate(context: ExecutionContext): boolean { + const options = this.reflector.getAllAndOverride( + RATE_LIMIT_KEY, + [context.getHandler(), context.getClass()], + ) + + if (!options) { + return true // No rate limit configured — allow the request + } + + const request = context.switchToHttp().getRequest() + const clientKey = this.extractClientKey(request) + const routeKey = this.getRouteKey(context) + const mapKey = `${routeKey}::${clientKey}` + + const now = Date.now() + const record = this.hits.get(mapKey) + + if (!record || now > record.resetTime) { + // First request in window or window expired — start a new window + this.hits.set(mapKey, { + count: 1, + resetTime: now + options.windowMs, + }) + return true + } + + if (record.count >= options.limit) { + const retryAfter = Math.ceil((record.resetTime - now) / 1000) + this.logger.warn( + `Rate limit exceeded for ${clientKey} on ${routeKey} ` + + `(${record.count}/${options.limit} in ${options.windowMs / 1000}s)`, + ) + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Rate limit exceeded. Please try again later.', + error: 'Too Many Requests', + retryAfter, + }, + HttpStatus.TOO_MANY_REQUESTS, + ) + } + + record.count++ + return true + } + + /** + * Extract a client identifier from the request. + * Uses a custom header (X-Forwarded-For), then falls back to remote IP. + */ + private extractClientKey(request: Record): string { + const headers = request.headers as Record | undefined + if (headers) { + const forwarded = headers['x-forwarded-for'] + if (forwarded) { + return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0].trim() + } + } + return (request.ip as string) || 'unknown' + } + + /** Build a unique key for the route handler. */ + private getRouteKey(context: ExecutionContext): string { + const handler = context.getHandler() + const className = context.getClass()?.name || 'Unknown' + return `${className}.${handler.name}` + } + + /** Remove expired entries to prevent unbounded memory growth. */ + private cleanup(): void { + const now = Date.now() + let cleaned = 0 + for (const [key, record] of this.hits) { + if (now > record.resetTime) { + this.hits.delete(key) + cleaned++ + } + } + if (cleaned > 0) { + this.logger.debug(`Rate limit cleanup: removed ${cleaned} expired entries`) + } + } +} diff --git a/backend/src/common/guards/ws-rate-limit.guard.ts b/backend/src/common/guards/ws-rate-limit.guard.ts new file mode 100644 index 00000000..03fb6c96 --- /dev/null +++ b/backend/src/common/guards/ws-rate-limit.guard.ts @@ -0,0 +1,103 @@ +import { Logger } from '@nestjs/common' +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { Socket } from 'socket.io' + +export interface WsRateLimitOptions { + /** Maximum messages allowed within the time window */ + limit: number + /** Time window in milliseconds */ + windowMs: number +} + +interface WsRequestRecord { + count: number + resetTime: number +} + +/** + * Rate-limit guard for WebSocket event handlers. + * + * Attach via @UseGuards(WsRateLimitGuard) on the gateway or individual + * @SubscribeMessage handlers. Configure per-handler limits through the + * metadata key 'wsRateLimit' set by the @WsRateLimit() decorator. + * + * Defaults (when no metadata is present): 60 messages / 60 s. + */ +export class WsRateLimitGuard implements CanActivate { + private readonly logger = new Logger(WsRateLimitGuard.name) + private readonly hits = new Map() + + constructor() { + // Periodic cleanup every 5 minutes + setInterval(() => this.cleanup(), 5 * 60 * 1000) + } + + canActivate(context: ExecutionContext): boolean { + const client: Socket = context.switchToWs().getClient() + const data: unknown = context.switchToWs().getData() + const handler = context.getHandler() + const className = context.getClass()?.name || 'Unknown' + + // Read per-handler limit from metadata (set by @WsRateLimit decorator) + const metaKey = 'wsRateLimit' + const options: WsRateLimitOptions = + Reflect.getMetadata(metaKey, handler) || + Reflect.getMetadata(metaKey, className, handler.name) || { + limit: 60, + windowMs: 60_000, + } + + const clientId = this.getClientId(client) + const routeKey = `${className}.${handler.name}` + const mapKey = `${routeKey}::${clientId}` + + const now = Date.now() + const record = this.hits.get(mapKey) + + if (!record || now > record.resetTime) { + this.hits.set(mapKey, { count: 1, resetTime: now + options.windowMs }) + return true + } + + if (record.count >= options.limit) { + const retryAfter = Math.ceil((record.resetTime - now) / 1000) + this.logger.warn( + `WS rate limit exceeded for ${clientId} on ${routeKey} ` + + `(${record.count}/${options.limit} in ${options.windowMs / 1000}s)`, + ) + // Emit error event back to the client + client.emit('error', { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Too many requests. Please slow down.', + retryAfter, + }) + return false + } + + record.count++ + return true + } + + private getClientId(client: Socket): string { + // Prefer a userId stored during auth handshake, fall back to socket ID + const data = client.data as Record + if (data && typeof data.userId === 'string') { + return data.userId + } + return client.id || 'unknown' + } + + private cleanup(): void { + const now = Date.now() + let cleaned = 0 + for (const [key, record] of this.hits) { + if (now > record.resetTime) { + this.hits.delete(key) + cleaned++ + } + } + if (cleaned > 0) { + this.logger.debug(`WS rate limit cleanup: removed ${cleaned} expired entries`) + } + } +} diff --git a/backend/src/in-app-notifications/dto/create-notification.dto.ts b/backend/src/in-app-notifications/dto/create-notification.dto.ts index 6c11f7aa..6cb5df0f 100644 --- a/backend/src/in-app-notifications/dto/create-notification.dto.ts +++ b/backend/src/in-app-notifications/dto/create-notification.dto.ts @@ -1,22 +1,18 @@ -import { - IsString, - IsEnum, - IsOptional, - IsNumber, - IsNotEmpty, -} from 'class-validator'; +import { IsString, IsEnum, IsOptional, IsNumber, IsNotEmpty, MaxLength } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { InAppNotificationType } from '../entities/in-app-notification.entity'; export class CreateNotificationDto { - @ApiProperty({ description: 'Title of the notification' }) + @ApiProperty({ description: 'Title of the notification', maxLength: 200 }) @IsString() @IsNotEmpty() + @MaxLength(200, { message: 'Notification title must be 200 characters or fewer' }) title: string; - @ApiProperty({ description: 'Message content of the notification' }) + @ApiProperty({ description: 'Message content of the notification', maxLength: 2000 }) @IsString() @IsNotEmpty() + @MaxLength(2000, { message: 'Notification message must be 2000 characters or fewer' }) message: string; @ApiProperty({ diff --git a/backend/src/in-app-notifications/dto/mark-read.dto.ts b/backend/src/in-app-notifications/dto/mark-read.dto.ts index e68d65b4..4e3b1b9b 100644 --- a/backend/src/in-app-notifications/dto/mark-read.dto.ts +++ b/backend/src/in-app-notifications/dto/mark-read.dto.ts @@ -1,4 +1,4 @@ -import { IsArray, IsNumber } from 'class-validator'; +import { IsArray, IsNumber, ArrayMaxSize } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class MarkReadDto { @@ -8,5 +8,6 @@ export class MarkReadDto { }) @IsArray() @IsNumber({}, { each: true }) + @ArrayMaxSize(100, { message: 'Cannot mark more than 100 notifications at once' }) notificationIds: number[]; } diff --git a/backend/src/in-app-notifications/dto/system-notification.dto.ts b/backend/src/in-app-notifications/dto/system-notification.dto.ts index d4a209ba..fc3d2b1c 100644 --- a/backend/src/in-app-notifications/dto/system-notification.dto.ts +++ b/backend/src/in-app-notifications/dto/system-notification.dto.ts @@ -1,16 +1,18 @@ -import { IsString, IsEnum, IsNotEmpty } from 'class-validator'; +import { IsString, IsEnum, IsNotEmpty, MaxLength } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { InAppNotificationType } from '../entities/in-app-notification.entity'; export class SystemNotificationDto { - @ApiProperty({ description: 'Title of the system notification' }) + @ApiProperty({ description: 'Title of the system notification', maxLength: 200 }) @IsString() @IsNotEmpty() + @MaxLength(200, { message: 'Notification title must be 200 characters or fewer' }) title: string; - @ApiProperty({ description: 'Message content of the system notification' }) + @ApiProperty({ description: 'Message content of the system notification', maxLength: 2000 }) @IsString() @IsNotEmpty() + @MaxLength(2000, { message: 'Notification message must be 2000 characters or fewer' }) message: string; @ApiProperty({ diff --git a/backend/src/in-app-notifications/in-app-notifications.controller.ts b/backend/src/in-app-notifications/in-app-notifications.controller.ts index 293f4d38..e4a33394 100644 --- a/backend/src/in-app-notifications/in-app-notifications.controller.ts +++ b/backend/src/in-app-notifications/in-app-notifications.controller.ts @@ -9,6 +9,7 @@ import { Query, ParseIntPipe, HttpStatus, + UseGuards, } from '@nestjs/common'; import { ApiTags, @@ -24,23 +25,24 @@ import { SystemNotificationDto } from './dto/system-notification.dto'; import { MarkReadDto } from './dto/mark-read.dto'; import { NotificationResponseDto } from './dto/notification-response.dto'; import { InAppNotificationType } from './entities/in-app-notification.entity'; +import { RateLimitGuard } from '../common/guards/rate-limit.guard'; +import { RateLimit } from '../common/decorators/rate-limit.decorator'; @ApiTags('In-App Notifications') @Controller('in-app-notifications') @ApiBearerAuth() +@UseGuards(RateLimitGuard) export class InAppNotificationsController { constructor( private readonly notificationsService: InAppNotificationsService, ) {} @Get() + @RateLimit({ limit: 30, windowMs: 60_000 }) @ApiOperation({ summary: 'Get all notifications for the authenticated user' }) @ApiQuery({ name: 'type', enum: InAppNotificationType, required: false }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Notifications retrieved successfully', - type: [NotificationResponseDto], - }) + @ApiResponse({ status: HttpStatus.OK, description: 'Notifications retrieved successfully', type: [NotificationResponseDto] }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) async getUserNotifications( @Query('userId', ParseIntPipe) userId: number, // In real app, this would come from JWT token @Query('type') type?: InAppNotificationType, @@ -49,14 +51,10 @@ export class InAppNotificationsController { } @Get('unread-count') - @ApiOperation({ - summary: 'Get count of unread notifications for the authenticated user', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Unread count retrieved successfully', - type: Number, - }) + @RateLimit({ limit: 30, windowMs: 60_000 }) + @ApiOperation({ summary: 'Get count of unread notifications for the authenticated user' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Unread count retrieved successfully', type: Number }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) async getUnreadCount( @Query('userId', ParseIntPipe) userId: number, // In real app, this would come from JWT token ): Promise { @@ -64,47 +62,30 @@ export class InAppNotificationsController { } @Post() + @RateLimit({ limit: 10, windowMs: 60_000 }) @ApiOperation({ summary: 'Create a new notification' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'Notification created successfully', - type: NotificationResponseDto, - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input data', - }) - async createNotification( - @Body() createNotificationDto: CreateNotificationDto, - ) { + @ApiResponse({ status: HttpStatus.CREATED, description: 'Notification created successfully', type: NotificationResponseDto }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) + async createNotification(@Body() createNotificationDto: CreateNotificationDto) { return this.notificationsService.createNotification(createNotificationDto); } @Post('system') + @RateLimit({ limit: 5, windowMs: 60_000 }) @ApiOperation({ summary: 'Create a system-wide notification' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'System notification created successfully', - type: [NotificationResponseDto], - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input data', - }) - async createSystemNotification( - @Body() systemNotificationDto: SystemNotificationDto, - ) { - return this.notificationsService.createSystemNotification( - systemNotificationDto, - ); + @ApiResponse({ status: HttpStatus.CREATED, description: 'System notification created successfully', type: [NotificationResponseDto] }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input data' }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) + async createSystemNotification(@Body() systemNotificationDto: SystemNotificationDto) { + return this.notificationsService.createSystemNotification(systemNotificationDto); } @Patch('read') + @RateLimit({ limit: 30, windowMs: 60_000 }) @ApiOperation({ summary: 'Mark specific notifications as read' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Notifications marked as read successfully', - }) + @ApiResponse({ status: HttpStatus.OK, description: 'Notifications marked as read successfully' }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) async markAsRead( @Query('userId', ParseIntPipe) userId: number, // In real app, this would come from JWT token @Body() markReadDto: MarkReadDto, @@ -113,13 +94,10 @@ export class InAppNotificationsController { } @Patch('read-all') - @ApiOperation({ - summary: 'Mark all notifications as read for the authenticated user', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'All notifications marked as read successfully', - }) + @RateLimit({ limit: 10, windowMs: 60_000 }) + @ApiOperation({ summary: 'Mark all notifications as read for the authenticated user' }) + @ApiResponse({ status: HttpStatus.OK, description: 'All notifications marked as read successfully' }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) async markAllAsRead( @Query('userId', ParseIntPipe) userId: number, // In real app, this would come from JWT token ) { @@ -127,11 +105,10 @@ export class InAppNotificationsController { } @Patch('archive') + @RateLimit({ limit: 30, windowMs: 60_000 }) @ApiOperation({ summary: 'Archive specific notifications' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Notifications archived successfully', - }) + @ApiResponse({ status: HttpStatus.OK, description: 'Notifications archived successfully' }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) async archiveNotifications( @Query('userId', ParseIntPipe) userId: number, // In real app, this would come from JWT token @Body() markReadDto: MarkReadDto, @@ -143,16 +120,12 @@ export class InAppNotificationsController { } @Delete(':id') + @RateLimit({ limit: 20, windowMs: 60_000 }) @ApiOperation({ summary: 'Delete a specific notification' }) @ApiParam({ name: 'id', description: 'Notification ID' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Notification deleted successfully', - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Notification not found', - }) + @ApiResponse({ status: HttpStatus.OK, description: 'Notification deleted successfully' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Notification not found' }) + @ApiResponse({ status: HttpStatus.TOO_MANY_REQUESTS, description: 'Rate limit exceeded' }) async deleteNotification( @Query('userId', ParseIntPipe) userId: number, // In real app, this would come from JWT token @Param('id', ParseIntPipe) id: number, diff --git a/backend/src/in-app-notifications/in-app-notifications.gateway.ts b/backend/src/in-app-notifications/in-app-notifications.gateway.ts new file mode 100644 index 00000000..8be2bd6d --- /dev/null +++ b/backend/src/in-app-notifications/in-app-notifications.gateway.ts @@ -0,0 +1,237 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets' +import { Logger, UseGuards } from '@nestjs/common' +import { Server, Socket } from 'socket.io' +import type { InAppNotificationsService } from './in-app-notifications.service' +import { CreateNotificationDto } from './dto/create-notification.dto' +import { SystemNotificationDto } from './dto/system-notification.dto' +import type { InAppNotification } from './entities/in-app-notification.entity' +import { WsRateLimitGuard } from '../common/guards/ws-rate-limit.guard' +import { WsRateLimit } from '../common/decorators/ws-rate-limit.decorator' + +/** Maximum allowed JSON payload size for a single WebSocket message (bytes) */ +const MAX_PAYLOAD_BYTES = 8_192 + +@WebSocketGateway({ + namespace: '/notifications', + cors: { origin: '*' }, +}) +@UseGuards(WsRateLimitGuard) +export class NotificationsGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server + + private readonly logger = new Logger(NotificationsGateway.name) + + /** Track which rooms (userId channels) are occupied for broadcast */ + private readonly userRooms = new Map>() + + constructor(private readonly notificationsService: InAppNotificationsService) {} + + handleConnection(client: Socket): void { + this.logger.log(`Client connected to notifications: ${client.id}`) + } + + handleDisconnect(client: Socket): void { + this.logger.log(`Client disconnected from notifications: ${client.id}`) + // Clean up room membership + const rooms = client.rooms + for (const room of rooms) { + if (room === client.id) continue // skip the default room + const members = this.userRooms.get(room) + if (members) { + members.delete(client.id) + if (members.size === 0) this.userRooms.delete(room) + } + } + } + + /** + * Client requests to join their personal notification channel. + */ + @WsRateLimit({ limit: 5, windowMs: 60_000 }) + @SubscribeMessage('subscribe') + async handleSubscribe( + @ConnectedSocket() client: Socket, + @MessageBody() data: { userId: string }, + ): Promise<{ event: string; data: { success: boolean } | { error: string } }> { + if (!data?.userId) { + return { event: 'subscribeError', data: { error: 'userId is required' } } + } + + const room = `user_${data.userId}` + await client.join(room) + + if (!this.userRooms.has(room)) { + this.userRooms.set(room, new Set()) + } + this.userRooms.get(room).add(client.id) + + this.logger.log(`Client ${client.id} subscribed to ${room}`) + return { event: 'subscribed', data: { success: true } } + } + + /** + * Client sends a notification to a specific user (admin/system use). + */ + @WsRateLimit({ limit: 10, windowMs: 60_000 }) + @SubscribeMessage('sendNotification') + async handleSendNotification( + @ConnectedSocket() client: Socket, + @MessageBody() data: CreateNotificationDto, + ): Promise<{ event: string; data: InAppNotification | { error: string } }> { + const validationError = this.validateNotificationPayload(data) + if (validationError) { + return { event: 'sendError', data: { error: validationError } } + } + + try { + const notification = + await this.notificationsService.createNotification(data) + + // Push to the target user's room (if they're connected) + if (data.userId) { + this.server + .to(`user_${data.userId}`) + .emit('notification', notification) + } + + return { event: 'sendSuccess', data: notification } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to send notification' + return { event: 'sendError', data: { error: message } } + } + } + + /** + * Client sends a system-wide notification broadcast. + */ + @WsRateLimit({ limit: 3, windowMs: 60_000 }) + @SubscribeMessage('broadcastNotification') + async handleBroadcastNotification( + @ConnectedSocket() client: Socket, + @MessageBody() data: SystemNotificationDto, + ): Promise<{ event: string; data: InAppNotification[] | { error: string } }> { + const validationError = this.validateSystemNotificationPayload(data) + if (validationError) { + return { event: 'broadcastError', data: { error: validationError } } + } + + try { + const notifications = + await this.notificationsService.createSystemNotification(data) + + // Broadcast to all connected clients + this.server.emit('notification', notifications[0]) + + return { event: 'broadcastSuccess', data: notifications } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to broadcast notification' + return { event: 'broadcastError', data: { error: message } } + } + } + + /** + * Fetch unread count for a user. + */ + @WsRateLimit({ limit: 30, windowMs: 60_000 }) + @SubscribeMessage('getUnreadCount') + async handleGetUnreadCount( + @ConnectedSocket() _client: Socket, + @MessageBody() data: { userId: number }, + ): Promise<{ event: string; data: number | { error: string } }> { + if (data?.userId === undefined || data?.userId === null) { + return { event: 'unreadCountError', data: { error: 'userId is required' } } + } + + try { + const count = await this.notificationsService.getUnreadCount(data.userId) + return { event: 'unreadCount', data: count } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to get unread count' + return { event: 'unreadCountError', data: { error: message } } + } + } + + // ── Payload validation helpers ────────────────────────────────────────── + + private validateNotificationPayload(data: unknown): string | null { + if (!data || typeof data !== 'object') { + return 'Payload must be a non-null object' + } + const obj = data as Record + + try { + const byteLength = Buffer.byteLength(JSON.stringify(obj), 'utf-8') + if (byteLength > MAX_PAYLOAD_BYTES) { + return `Payload exceeds maximum size of ${MAX_PAYLOAD_BYTES} bytes` + } + } catch { + return 'Payload could not be serialised' + } + + if (!obj.title || typeof obj.title !== 'string') { + return 'Field \'title\' is required and must be a string' + } + if (obj.title.length > 200) { + return 'Notification title must be 200 characters or fewer' + } + if (!obj.message || typeof obj.message !== 'string') { + return 'Field \'message\' is required and must be a string' + } + if (obj.message.length > 2000) { + return 'Notification message must be 2000 characters or fewer' + } + if (!obj.type || typeof obj.type !== 'string') { + return 'Field \'type\' is required and must be a string' + } + + return null + } + + private validateSystemNotificationPayload(data: unknown): string | null { + if (!data || typeof data !== 'object') { + return 'Payload must be a non-null object' + } + const obj = data as Record + + try { + const byteLength = Buffer.byteLength(JSON.stringify(obj), 'utf-8') + if (byteLength > MAX_PAYLOAD_BYTES) { + return `Payload exceeds maximum size of ${MAX_PAYLOAD_BYTES} bytes` + } + } catch { + return 'Payload could not be serialised' + } + + if (!obj.title || typeof obj.title !== 'string') { + return 'Field \'title\' is required and must be a string' + } + if (obj.title.length > 200) { + return 'Notification title must be 200 characters or fewer' + } + if (!obj.message || typeof obj.message !== 'string') { + return 'Field \'message\' is required and must be a string' + } + if (obj.message.length > 2000) { + return 'Notification message must be 2000 characters or fewer' + } + if (!obj.type || typeof obj.type !== 'string') { + return 'Field \'type\' is required and must be a string' + } + + return null + } +} diff --git a/backend/src/in-app-notifications/in-app-notifications.module.ts b/backend/src/in-app-notifications/in-app-notifications.module.ts index 783481bc..7fe158e6 100644 --- a/backend/src/in-app-notifications/in-app-notifications.module.ts +++ b/backend/src/in-app-notifications/in-app-notifications.module.ts @@ -3,12 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { InAppNotificationsController } from './in-app-notifications.controller'; import { InAppNotificationsService } from './in-app-notifications.service'; import { BroadcasterService } from './services/broadcaster.service'; +import { NotificationsGateway } from './in-app-notifications.gateway'; import { InAppNotification } from './entities/in-app-notification.entity'; @Module({ imports: [TypeOrmModule.forFeature([InAppNotification])], controllers: [InAppNotificationsController], - providers: [InAppNotificationsService, BroadcasterService], + providers: [InAppNotificationsService, BroadcasterService, NotificationsGateway], exports: [InAppNotificationsService, BroadcasterService], }) export class InAppNotificationsModule {} diff --git a/backend/src/multiplayer-queue/dto/join-queue.dto.ts b/backend/src/multiplayer-queue/dto/join-queue.dto.ts index 26d5b23c..b3d795aa 100644 --- a/backend/src/multiplayer-queue/dto/join-queue.dto.ts +++ b/backend/src/multiplayer-queue/dto/join-queue.dto.ts @@ -1,16 +1,6 @@ -import { - IsString, - IsNotEmpty, - IsUUID, - IsEnum, - IsOptional, - IsInt, - Min, - Max, - IsArray, -} from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SkillLevel } from '../entities/queue.entity'; +import { IsString, IsNotEmpty, IsUUID, IsEnum, IsOptional, IsInt, Min, Max, IsArray, ArrayMaxSize, MaxLength } from "class-validator" +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger" +import { SkillLevel } from "../entities/queue.entity" export class JoinQueueDto { @ApiProperty({ @@ -27,7 +17,8 @@ export class JoinQueueDto { }) @IsString() @IsNotEmpty() - username: string; + @MaxLength(100, { message: 'Username must be 100 characters or fewer' }) + username: string @ApiProperty({ description: "Player's skill level", @@ -44,7 +35,8 @@ export class JoinQueueDto { }) @IsString() @IsOptional() - gameMode?: string = 'classic'; + @MaxLength(50, { message: 'Game mode must be 50 characters or fewer' }) + gameMode?: string = "classic" @ApiPropertyOptional({ description: 'Maximum wait time in seconds', @@ -64,6 +56,7 @@ export class JoinQueueDto { }) @IsArray() @IsUUID(4, { each: true }) + @ArrayMaxSize(10, { message: 'Preferred opponents list must contain 10 or fewer entries' }) @IsOptional() preferredOpponents?: string[]; @@ -73,6 +66,7 @@ export class JoinQueueDto { }) @IsArray() @IsUUID(4, { each: true }) + @ArrayMaxSize(10, { message: 'Avoid opponents list must contain 10 or fewer entries' }) @IsOptional() avoidOpponents?: string[]; } diff --git a/backend/src/multiplayer-queue/multiplayer-queue.controller.ts b/backend/src/multiplayer-queue/multiplayer-queue.controller.ts index abd772b9..103f3ec6 100644 --- a/backend/src/multiplayer-queue/multiplayer-queue.controller.ts +++ b/backend/src/multiplayer-queue/multiplayer-queue.controller.ts @@ -1,113 +1,89 @@ -import { - Controller, - Get, - Post, - Delete, - Param, - HttpCode, - HttpStatus, - UseGuards, -} from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { OwnershipGuard } from '../common/guards/ownership.guard'; -import { Ownership } from '../common/decorators/ownership.decorator'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; -import type { MultiplayerQueueService } from './multiplayer-queue.service'; -import type { JoinQueueDto } from './dto/join-queue.dto'; -import { QueueStatusDto } from './dto/queue-status.dto'; -import { MatchResultDto } from './dto/match-result.dto'; -import { QueueStatsDto } from './dto/queue-stats.dto'; +import { Controller, Get, Post, Delete, Param, HttpCode, HttpStatus, UseGuards } from "@nestjs/common" +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger" +import type { MultiplayerQueueService } from "./multiplayer-queue.service" +import type { JoinQueueDto } from "./dto/join-queue.dto" +import { QueueStatusDto } from "./dto/queue-status.dto" +import { MatchResultDto } from "./dto/match-result.dto" +import { QueueStatsDto } from "./dto/queue-stats.dto" +import { RateLimitGuard } from "../common/guards/rate-limit.guard" +import { RateLimit } from "../common/decorators/rate-limit.decorator" -@ApiTags('Multiplayer Queue') -@Controller('multiplayer-queue') +@ApiTags("Multiplayer Queue") +@Controller("multiplayer-queue") +@UseGuards(RateLimitGuard) export class MultiplayerQueueController { constructor( private readonly multiplayerQueueService: MultiplayerQueueService, ) {} - @Post('join') - @ApiOperation({ summary: 'Join the multiplayer queue' }) - @ApiResponse({ - status: 201, - description: 'Successfully joined queue', - type: QueueStatusDto, - }) - @ApiResponse({ - status: 400, - description: 'User already in queue or invalid data', - }) + @Post("join") + @RateLimit({ limit: 5, windowMs: 60_000 }) + @ApiOperation({ summary: "Join the multiplayer queue" }) + @ApiResponse({ status: 201, description: "Successfully joined queue", type: QueueStatusDto }) + @ApiResponse({ status: 400, description: "User already in queue or invalid data" }) + @ApiResponse({ status: 429, description: "Rate limit exceeded" }) async joinQueue(joinQueueDto: JoinQueueDto): Promise { return await this.multiplayerQueueService.joinQueue(joinQueueDto); } @Delete('leave/:userId') @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AuthGuard('jwt'), OwnershipGuard) - @Ownership({ param: 'userId' }) - @ApiOperation({ summary: 'Leave the multiplayer queue' }) - @ApiParam({ name: 'userId', description: 'User ID to remove from queue' }) - @ApiResponse({ status: 204, description: 'Successfully left queue' }) - @ApiResponse({ status: 404, description: 'User not found in queue' }) - async leaveQueue(@Param('userId') userId: string): Promise { - await this.multiplayerQueueService.leaveQueue(userId); + @RateLimit({ limit: 10, windowMs: 60_000 }) + @ApiOperation({ summary: "Leave the multiplayer queue" }) + @ApiParam({ name: "userId", description: "User ID to remove from queue" }) + @ApiResponse({ status: 204, description: "Successfully left queue" }) + @ApiResponse({ status: 404, description: "User not found in queue" }) + @ApiResponse({ status: 429, description: "Rate limit exceeded" }) + async leaveQueue(@Param("userId") userId: string): Promise { + await this.multiplayerQueueService.leaveQueue(userId) } - @Get('status/:userId') - @UseGuards(AuthGuard('jwt'), OwnershipGuard) - @Ownership({ param: 'userId' }) - @ApiOperation({ summary: 'Get queue status for a user' }) - @ApiParam({ name: 'userId', description: 'User ID to check status' }) - @ApiResponse({ - status: 200, - description: 'Queue status retrieved', - type: QueueStatusDto, - }) - @ApiResponse({ status: 404, description: 'User not in queue' }) - async getQueueStatus( - @Param('userId') userId: string, - ): Promise { - return await this.multiplayerQueueService.getQueueStatus(userId); + @Get("status/:userId") + @RateLimit({ limit: 30, windowMs: 60_000 }) + @ApiOperation({ summary: "Get queue status for a user" }) + @ApiParam({ name: "userId", description: "User ID to check status" }) + @ApiResponse({ status: 200, description: "Queue status retrieved", type: QueueStatusDto }) + @ApiResponse({ status: 404, description: "User not in queue" }) + @ApiResponse({ status: 429, description: "Rate limit exceeded" }) + async getQueueStatus(@Param("userId") userId: string): Promise { + return await this.multiplayerQueueService.getQueueStatus(userId) } - @Get('list') - @ApiOperation({ summary: 'Get all users currently in queue' }) - @ApiResponse({ - status: 200, - description: 'Queue list retrieved', - type: [QueueStatusDto], - }) + @Get("list") + @RateLimit({ limit: 30, windowMs: 60_000 }) + @ApiOperation({ summary: "Get all users currently in queue" }) + @ApiResponse({ status: 200, description: "Queue list retrieved", type: [QueueStatusDto] }) + @ApiResponse({ status: 429, description: "Rate limit exceeded" }) async getQueueList(): Promise { return await this.multiplayerQueueService.getQueueList(); } - @Get('stats') - @ApiOperation({ summary: 'Get queue statistics' }) - @ApiResponse({ - status: 200, - description: 'Queue statistics retrieved', - type: QueueStatsDto, - }) + @Get("stats") + @RateLimit({ limit: 30, windowMs: 60_000 }) + @ApiOperation({ summary: "Get queue statistics" }) + @ApiResponse({ status: 200, description: "Queue statistics retrieved", type: QueueStatsDto }) + @ApiResponse({ status: 429, description: "Rate limit exceeded" }) async getQueueStats(): Promise { return await this.multiplayerQueueService.getQueueStats(); } - @Get('match/:matchId') - @ApiOperation({ summary: 'Get match details' }) - @ApiParam({ name: 'matchId', description: 'Match ID' }) - @ApiResponse({ - status: 200, - description: 'Match details retrieved', - type: MatchResultDto, - }) - @ApiResponse({ status: 404, description: 'Match not found' }) - async getMatch(@Param('matchId') matchId: string): Promise { - return await this.multiplayerQueueService.getMatch(matchId); + @Get("match/:matchId") + @RateLimit({ limit: 30, windowMs: 60_000 }) + @ApiOperation({ summary: "Get match details" }) + @ApiParam({ name: "matchId", description: "Match ID" }) + @ApiResponse({ status: 200, description: "Match details retrieved", type: MatchResultDto }) + @ApiResponse({ status: 404, description: "Match not found" }) + @ApiResponse({ status: 429, description: "Rate limit exceeded" }) + async getMatch(@Param("matchId") matchId: string): Promise { + return await this.multiplayerQueueService.getMatch(matchId) } @Post('process-matchmaking') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Manually trigger matchmaking process' }) - @ApiResponse({ status: 200, description: 'Matchmaking process triggered' }) + @RateLimit({ limit: 2, windowMs: 60_000 }) + @ApiOperation({ summary: "Manually trigger matchmaking process" }) + @ApiResponse({ status: 200, description: "Matchmaking process triggered" }) + @ApiResponse({ status: 429, description: "Rate limit exceeded" }) async processMatchmaking(): Promise<{ message: string }> { await this.multiplayerQueueService.processMatchmaking(); return { message: 'Matchmaking process completed' }; diff --git a/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts b/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts index aa03e637..b46b205f 100644 --- a/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts +++ b/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts @@ -1,44 +1,175 @@ -import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common'; -import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; -import type { Server } from 'socket.io'; -import type { MatchResultDto } from './dto/match-result.dto'; - -/** - * MultiplayerQueueGateway - * ------------------------ - * Real-time matchmaking transport. When the matchmaking cron - * (`MultiplayerQueueService.processMatchmaking`) pairs two players it emits a - * `match_found` event so both clients can transition out of the queue - * immediately instead of polling (#GracefulShutdown / realtime UX). - * - * The Socket.IO server shares the Nest HTTP server, so `app.close()` already - * stops accepting new socket connections. We additionally implement - * `onApplicationShutdown` to explicitly close the Socket.IO server and let - * in-flight emits flush before the process exits. - */ -@Injectable() +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets' +import { Logger, UseGuards } from '@nestjs/common' +import { Server, Socket } from 'socket.io' +import type { MultiplayerQueueService } from './multiplayer-queue.service' +import type { JoinQueueDto } from './dto/join-queue.dto' +import { QueueStatusDto } from './dto/queue-status.dto' +import { WsRateLimitGuard } from '../common/guards/ws-rate-limit.guard' +import { WsRateLimit } from '../common/decorators/ws-rate-limit.decorator' + +/** Maximum allowed JSON payload size for a single WebSocket message (bytes) */ +const MAX_PAYLOAD_BYTES = 8_192 + @WebSocketGateway({ - // Mirror the HTTP CORS posture; tighten via env in production. - cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true }, - path: '/socket.io', + namespace: '/multiplayer', + cors: { origin: '*' }, }) -export class MultiplayerQueueGateway implements OnApplicationShutdown { - private readonly logger = new Logger(MultiplayerQueueGateway.name); - +@UseGuards(WsRateLimitGuard) +export class MultiplayerGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ @WebSocketServer() - server: Server; + server: Server + + private readonly logger = new Logger(MultiplayerGateway.name) + + constructor(private readonly queueService: MultiplayerQueueService) {} + + handleConnection(client: Socket): void { + this.logger.log(`Client connected to multiplayer: ${client.id}`) + } + + handleDisconnect(client: Socket): void { + this.logger.log(`Client disconnected from multiplayer: ${client.id}`) + } - /** Notify every connected client that a match was created. */ - notifyMatchCreated(match: MatchResultDto): void { - if (!this.server) return; - this.server.emit('match_found', match); + /** + * Handle a player joining the matchmaking queue. + */ + @WsRateLimit({ limit: 5, windowMs: 60_000 }) + @SubscribeMessage('joinQueue') + async handleJoinQueue( + @ConnectedSocket() client: Socket, + @MessageBody() data: JoinQueueDto, + ): Promise<{ event: string; data: QueueStatusDto | { error: string } }> { + const validationError = this.validatePayload(data, { + requiredFields: ['userId', 'username', 'skillLevel'], + maxStringLengths: { username: 100, gameMode: 50 }, + maxArraySizes: { preferredOpponents: 10, avoidOpponents: 10 }, + }) + if (validationError) { + return { event: 'joinQueueError', data: { error: validationError } } + } + + try { + const status = await this.queueService.joinQueue(data) + return { event: 'joinQueueSuccess', data: status } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to join queue' + return { event: 'joinQueueError', data: { error: message } } + } + } + + /** + * Handle a player leaving the matchmaking queue. + */ + @WsRateLimit({ limit: 10, windowMs: 60_000 }) + @SubscribeMessage('leaveQueue') + async handleLeaveQueue( + @ConnectedSocket() client: Socket, + @MessageBody() data: { userId: string }, + ): Promise<{ event: string; data: { success: boolean } | { error: string } }> { + if (!data?.userId) { + return { event: 'leaveQueueError', data: { error: 'userId is required' } } + } + + try { + await this.queueService.leaveQueue(data.userId) + return { event: 'leaveQueueSuccess', data: { success: true } } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to leave queue' + return { event: 'leaveQueueError', data: { error: message } } + } } - onApplicationShutdown(): void { - if (this.server) { - this.server.close(() => { - this.logger.log('Socket.IO server closed.'); - }); + /** + * Allow clients to subscribe to queue status updates. + */ + @WsRateLimit({ limit: 30, windowMs: 60_000 }) + @SubscribeMessage('getQueueStatus') + async handleGetStatus( + @ConnectedSocket() client: Socket, + @MessageBody() data: { userId: string }, + ): Promise<{ event: string; data: QueueStatusDto | null | { error: string } }> { + if (!data?.userId) { + return { event: 'queueStatusError', data: { error: 'userId is required' } } } + + try { + const status = await this.queueService.getQueueStatus(data.userId) + return { event: 'queueStatus', data: status } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to get status' + return { event: 'queueStatusError', data: { error: message } } + } + } + + // ── Payload validation helpers ────────────────────────────────────────── + + private validatePayload( + data: unknown, + rules: { + requiredFields?: string[] + maxStringLengths?: Record + maxArraySizes?: Record + }, + ): string | null { + if (!data || typeof data !== 'object') { + return 'Payload must be a non-null object' + } + + const obj = data as Record + + // Check raw size (defensive — JSON.stringify on the parsed object) + try { + const byteLength = Buffer.byteLength(JSON.stringify(obj), 'utf-8') + if (byteLength > MAX_PAYLOAD_BYTES) { + return `Payload exceeds maximum size of ${MAX_PAYLOAD_BYTES} bytes` + } + } catch { + return 'Payload could not be serialised' + } + + // Required fields + if (rules.requiredFields) { + for (const field of rules.requiredFields) { + if (obj[field] === undefined || obj[field] === null) { + return `Missing required field: ${field}` + } + } + } + + // String length limits + if (rules.maxStringLengths) { + for (const [field, maxLen] of Object.entries(rules.maxStringLengths)) { + const val = obj[field] + if (typeof val === 'string' && val.length > maxLen) { + return `Field '${field}' must be ${maxLen} characters or fewer` + } + } + } + + // Array size limits + if (rules.maxArraySizes) { + for (const [field, maxLen] of Object.entries(rules.maxArraySizes)) { + const val = obj[field] + if (Array.isArray(val) && val.length > maxLen) { + return `Field '${field}' must contain ${maxLen} or fewer entries` + } + } + } + + return null } } diff --git a/backend/src/multiplayer-queue/multiplayer-queue.module.ts b/backend/src/multiplayer-queue/multiplayer-queue.module.ts index 85dc45d3..75da9a09 100644 --- a/backend/src/multiplayer-queue/multiplayer-queue.module.ts +++ b/backend/src/multiplayer-queue/multiplayer-queue.module.ts @@ -1,11 +1,11 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScheduleModule } from '@nestjs/schedule'; -import { MultiplayerQueueService } from './multiplayer-queue.service'; -import { MultiplayerQueueGateway } from './multiplayer-queue.gateway'; -import { MultiplayerQueueController } from './multiplayer-queue.controller'; -import { Queue } from './entities/queue.entity'; -import { Match } from './entities/match.entity'; +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { ScheduleModule } from "@nestjs/schedule" +import { MultiplayerQueueService } from "./multiplayer-queue.service" +import { MultiplayerQueueController } from "./multiplayer-queue.controller" +import { MultiplayerGateway } from "./multiplayer-queue.gateway" +import { Queue } from "./entities/queue.entity" +import { Match } from "./entities/match.entity" @Module({ imports: [ @@ -13,7 +13,7 @@ import { Match } from './entities/match.entity'; ScheduleModule.forRoot(), // Enable cron jobs ], controllers: [MultiplayerQueueController], - providers: [MultiplayerQueueService, MultiplayerQueueGateway], + providers: [MultiplayerQueueService, MultiplayerGateway], exports: [MultiplayerQueueService], // Export for potential use in other modules }) export class MultiplayerQueueModule {}