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
23 changes: 23 additions & 0 deletions backend/src/common/decorators/rate-limit.decorator.ts
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions backend/src/common/decorators/ws-rate-limit.decorator.ts
Original file line number Diff line number Diff line change
@@ -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
}
115 changes: 115 additions & 0 deletions backend/src/common/guards/rate-limit.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string, RequestRecord>()

/** Periodic cleanup timer (10 minute interval) */
private readonly cleanupInterval: ReturnType<typeof setInterval>

constructor(private readonly reflector: Reflector) {
this.cleanupInterval = setInterval(() => this.cleanup(), 10 * 60 * 1000)
}

canActivate(context: ExecutionContext): boolean {
const options = this.reflector.getAllAndOverride<RateLimitOptions>(
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, unknown>): string {
const headers = request.headers as Record<string, string | string[]> | 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`)
}
}
}
103 changes: 103 additions & 0 deletions backend/src/common/guards/ws-rate-limit.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string, WsRequestRecord>()

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<string, unknown>
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`)
}
}
}
14 changes: 5 additions & 9 deletions backend/src/in-app-notifications/dto/create-notification.dto.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
3 changes: 2 additions & 1 deletion backend/src/in-app-notifications/dto/mark-read.dto.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -8,5 +8,6 @@ export class MarkReadDto {
})
@IsArray()
@IsNumber({}, { each: true })
@ArrayMaxSize(100, { message: 'Cannot mark more than 100 notifications at once' })
notificationIds: number[];
}
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
Loading
Loading