diff --git a/README.md b/README.md index dba241b4..f9b541df 100644 --- a/README.md +++ b/README.md @@ -213,25 +213,25 @@ TeachLink Backend follows a **modular microservices architecture** built on Nest ## 📦 Tech Stack -| Layer | Technology | Purpose | -| ----------------- | ------------------------------ | ------------------------------------ | -| **Framework** | NestJS | Node.js application framework | -| **Language** | TypeScript | Type-safe JavaScript | -| **Database** | PostgreSQL + TypeORM | Primary data storage | -| **Caching** | Redis + IORedis | Session store, caching, queues | -| **Authentication**| JWT + Passport | Token-based authentication | -| **GraphQL** | Apollo Server | GraphQL API (optional) | -| **Real-time** | Socket.io | WebSocket connections | -| **File Storage** | AWS S3 + Cloudinary | Media file storage and CDN | -| **Email** | SendGrid + Nodemailer | Email delivery and marketing | -| **Payments** | Stripe | Payment processing | -| **Search** | Elasticsearch | Full-text search capabilities | -| **Queue** | BullMQ | Background job processing | -| **Monitoring** | OpenTelemetry + Prometheus | Metrics and observability | -| **Testing** | Jest + Supertest | Unit and integration tests | -| **Documentation** | Swagger | API documentation | -| **Validation** | class-validator + class-transformer | DTO validation | -| **Security** | Helmet + bcrypt | Security headers and password hashing | +| Layer | Technology | Purpose | +| ------------------ | ----------------------------------- | ------------------------------------- | +| **Framework** | NestJS | Node.js application framework | +| **Language** | TypeScript | Type-safe JavaScript | +| **Database** | PostgreSQL + TypeORM | Primary data storage | +| **Caching** | Redis + IORedis | Session store, caching, queues | +| **Authentication** | JWT + Passport | Token-based authentication | +| **GraphQL** | Apollo Server | GraphQL API (optional) | +| **Real-time** | Socket.io | WebSocket connections | +| **File Storage** | AWS S3 + Cloudinary | Media file storage and CDN | +| **Email** | SendGrid + Nodemailer | Email delivery and marketing | +| **Payments** | Stripe | Payment processing | +| **Search** | Elasticsearch | Full-text search capabilities | +| **Queue** | BullMQ | Background job processing | +| **Monitoring** | OpenTelemetry + Prometheus | Metrics and observability | +| **Testing** | Jest + Supertest | Unit and integration tests | +| **Documentation** | Swagger | API documentation | +| **Validation** | class-validator + class-transformer | DTO validation | +| **Security** | Helmet + bcrypt | Security headers and password hashing | ## �️ Database diff --git a/src/collaboration/gateway/collaboration.gateway.ts b/src/collaboration/gateway/collaboration.gateway.ts index b60d012e..ae02702e 100644 --- a/src/collaboration/gateway/collaboration.gateway.ts +++ b/src/collaboration/gateway/collaboration.gateway.ts @@ -9,8 +9,9 @@ import { ConnectedSocket, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { Logger } from '@nestjs/common'; +import { Logger, UseGuards } from '@nestjs/common'; import { CollaborationService } from '../collaboration.service'; +import { WsThrottlerGuard } from '../../common/guards/ws-throttler.guard'; import { SharedDocumentService } from '../documents/shared-document.service'; import { WhiteboardService } from '../whiteboard/whiteboard.service'; import { VersionControlService } from '../versioning/version-control.service'; @@ -34,6 +35,7 @@ export interface CollaborativeOperation { credentials: true, }, }) +@UseGuards(WsThrottlerGuard) export class CollaborationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @@ -53,6 +55,11 @@ export class CollaborationGateway } async handleConnection(_server: any, @ConnectedSocket() client: Socket) { + if (wsManager.getTotalConnections() >= 5000) { + client.emit('error', { message: 'Server is at maximum capacity' }); + client.disconnect(true); + return; + } this.logger.log(`Client connected: ${client.id}`); // Optionally authenticate the user here based on token @@ -61,6 +68,7 @@ export class CollaborationGateway } async handleDisconnect(@ConnectedSocket() client: Socket) { + wsManager.cleanupSocket(client); this.logger.log(`Client disconnected: ${client.id}`); // Clean up any session associations for this client @@ -82,6 +90,12 @@ export class CollaborationGateway return; } + const registered = wsManager.registerConnection(userId, client); + if (!registered) { + client.emit('error', { message: 'Connection limit reached' }); + return; + } + // Join the room client.join(sessionId); diff --git a/src/common/guards/ws-throttler.guard.ts b/src/common/guards/ws-throttler.guard.ts new file mode 100644 index 00000000..b74d6c63 --- /dev/null +++ b/src/common/guards/ws-throttler.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, ExecutionContext, Logger } from '@nestjs/common'; +import { ThrottlerGuard, ThrottlerLimitDetail } from '@nestjs/throttler'; +import { WsException } from '@nestjs/websockets'; + +@Injectable() +export class WsThrottlerGuard extends ThrottlerGuard { + private readonly wsLogger = new Logger(WsThrottlerGuard.name); + + protected async getTracker(req: Record): Promise { + const user = req.user; + const ip = req.conn?.remoteAddress || req.request?.connection?.remoteAddress || 'unknown'; + return user?.sub || user?.id || ip; + } + + protected async throwThrottlingException( + context: ExecutionContext, + _throttlerLimitDetail: ThrottlerLimitDetail, + ): Promise { + const client = context.switchToWs().getClient(); + const tracker = await this.getTracker(client); + this.wsLogger.warn(`WebSocket rate limit exceeded for ${tracker}`); + throw new WsException('Rate limit exceeded'); + } +} diff --git a/src/common/utils/websocket.utils.ts b/src/common/utils/websocket.utils.ts index 58d99bc6..c63c7b80 100644 --- a/src/common/utils/websocket.utils.ts +++ b/src/common/utils/websocket.utils.ts @@ -11,6 +11,7 @@ class WebSocketManager { private meta = new Map(); // socketId -> meta private MAX_CONNECTIONS_PER_USER = 3; + private MAX_GLOBAL_CONNECTIONS = 5000; private HEARTBEAT_INTERVAL = 30000; // 30s private TIMEOUT = 60000; // 60s @@ -46,12 +47,13 @@ class WebSocketManager { this.connections.set(userId, new Set()); } - const userConnections = this.connections.get(userId) ?? new Set(); + const userConnections = this.connections.get(userId) || new Set(); - const userConnections = this.connections.get(userId); - - if (!userConnections) { - return; + // enforce global connection limits + if (this.meta.size >= this.MAX_GLOBAL_CONNECTIONS) { + socket.emit('error', { message: 'Server is at maximum capacity' }); + socket.disconnect(true); + return false; } // enforce max connections @@ -68,6 +70,7 @@ class WebSocketManager { lastSeen: Date.now(), isAlive: true, }); + return true; } cleanupSocket(socket: Socket) { diff --git a/src/gateways/messaging.gateway.ts b/src/gateways/messaging.gateway.ts index fc80f2cd..39b84845 100644 --- a/src/gateways/messaging.gateway.ts +++ b/src/gateways/messaging.gateway.ts @@ -4,15 +4,47 @@ import { MessageBody, ConnectedSocket, OnGatewayConnection, + OnGatewayDisconnect, } from '@nestjs/websockets'; -import { UseGuards } from '@nestjs/common'; +import { UseGuards, Logger } from '@nestjs/common'; import { Socket } from 'socket.io'; import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard'; +import { WsThrottlerGuard } from '../common/guards/ws-throttler.guard'; +import { wsManager } from '../common/utils/websocket.utils'; +import { JwtService } from '@nestjs/jwt'; @WebSocketGateway({ namespace: '/messaging' }) -export class MessagingGateway implements OnGatewayConnection { - async handleConnection(@ConnectedSocket() _client: Socket) { - // Guard will disconnect unauthorized clients +@UseGuards(WsThrottlerGuard) +export class MessagingGateway implements OnGatewayConnection, OnGatewayDisconnect { + private readonly logger = new Logger(MessagingGateway.name); + + constructor(private readonly jwtService: JwtService) {} + + async handleConnection(client: Socket) { + try { + const token = + client.handshake.auth?.token || client.handshake.headers?.authorization?.split(' ')[1]; + if (!token) { + throw new Error('No token provided'); + } + + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub || payload.id; + + const registered = wsManager.registerConnection(userId, client); + if (!registered) { + return; + } + this.logger.log(`Client connected: ${client.id}`); + } catch (_error) { + client.emit('error', { message: 'Unauthorized' }); + client.disconnect(true); + } + } + + handleDisconnect(client: Socket) { + wsManager.cleanupSocket(client); + this.logger.log(`Client disconnected: ${client.id}`); } @UseGuards(WsJwtAuthGuard) diff --git a/src/gateways/notifications.gateway.ts b/src/gateways/notifications.gateway.ts index bae625aa..ec5f494b 100644 --- a/src/gateways/notifications.gateway.ts +++ b/src/gateways/notifications.gateway.ts @@ -1,10 +1,52 @@ -import { WebSocketGateway, SubscribeMessage, ConnectedSocket } from '@nestjs/websockets'; -import { UseGuards } from '@nestjs/common'; +import { + WebSocketGateway, + SubscribeMessage, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { UseGuards, Logger } from '@nestjs/common'; import { Socket } from 'socket.io'; import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard'; +import { WsThrottlerGuard } from '../common/guards/ws-throttler.guard'; +import { wsManager } from '../common/utils/websocket.utils'; +import { JwtService } from '@nestjs/jwt'; @WebSocketGateway({ namespace: '/notifications' }) -export class NotificationsGateway { +@UseGuards(WsThrottlerGuard) +export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { + private readonly logger = new Logger(NotificationsGateway.name); + + constructor(private readonly jwtService: JwtService) {} + + async handleConnection(client: Socket) { + try { + const token = + client.handshake.auth?.token || client.handshake.headers?.authorization?.split(' ')[1]; + if (!token) { + throw new Error('No token provided'); + } + + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub || payload.id; + + const registered = wsManager.registerConnection(userId, client); + if (!registered) { + // Connection rejected by global limits + return; + } + this.logger.log(`Client connected: ${client.id}`); + } catch (_error) { + client.emit('error', { message: 'Unauthorized' }); + client.disconnect(true); + } + } + + handleDisconnect(client: Socket) { + wsManager.cleanupSocket(client); + this.logger.log(`Client disconnected: ${client.id}`); + } + @UseGuards(WsJwtAuthGuard) @SubscribeMessage('subscribe_notifications') async handleSubscribe(@ConnectedSocket() client: Socket) { diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index f822d1f1..8f34105f 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -4,7 +4,6 @@ import Redis from 'ioredis'; import { SkipThrottle } from '@nestjs/throttler'; import { HealthService } from './health.service'; - @Version(VERSION_NEUTRAL) @SkipThrottle() @Controller('health') diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 401f2845..9c45da32 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import { Redis } from 'ioredis'; import * as fs from 'fs'; import * as _path from 'path'; import axios from 'axios'; @@ -182,7 +182,7 @@ export class HealthService { }> { const startTime = Date.now(); try { - const pong = await redis.ping(); + const pong = await (redis as any).ping(); const responseTime = Date.now() - startTime; return { status: pong === 'PONG' && responseTime < 500 ? 'up' : 'degraded', diff --git a/src/main.ts b/src/main.ts index 397bc930..96cd87aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,7 +111,9 @@ async function bootstrapWorker() { // ─── Swagger ────────────────────────────────────────────────────────────── const config = new DocumentBuilder() .setTitle('TeachLink API') - .setDescription(`The TeachLink API documentation - Unified System. ${API_VERSIONING_DOCUMENTATION}`) + .setDescription( + `The TeachLink API documentation - Unified System. ${API_VERSIONING_DOCUMENTATION}`, + ) .setVersion('1.0') .addBearerAuth() .addTag('gamification', 'Gamification and user rewards') diff --git a/src/notifications/notifications.gateway.ts b/src/notifications/notifications.gateway.ts index ec89ce20..5eba6f7a 100644 --- a/src/notifications/notifications.gateway.ts +++ b/src/notifications/notifications.gateway.ts @@ -11,6 +11,8 @@ import { Server, Socket } from 'socket.io'; import { UseGuards, Logger } from '@nestjs/common'; import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard'; import { Notification } from './entities/notification.entity'; +import { wsManager } from '../common/utils/websocket.utils'; +import { WsThrottlerGuard } from '../common/guards/ws-throttler.guard'; @WebSocketGateway({ cors: { @@ -18,6 +20,7 @@ import { Notification } from './entities/notification.entity'; }, namespace: 'notifications', }) +@UseGuards(WsThrottlerGuard) export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; @@ -26,10 +29,20 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco private userSockets: Map> = new Map(); async handleConnection(client: Socket) { + // Optionally we can get userId here from token if required, but currently it handles subscribe via message. + // However, for connection limit we can temporarily register with client id if no token or if handled at subscribe. + // Let's enforce global connection limits here. + if (wsManager.getTotalConnections() >= 5000) { + // Same as MAX_GLOBAL_CONNECTIONS + client.emit('error', { message: 'Server is at maximum capacity' }); + client.disconnect(true); + return; + } this.logger.log(`Client connected: ${client.id}`); } handleDisconnect(client: Socket) { + wsManager.cleanupSocket(client); this.logger.log(`Client disconnected: ${client.id}`); this.removeSocketId(client.id); } @@ -48,6 +61,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco return; } + const registered = wsManager.registerConnection(userId, client); + if (!registered) { + return { status: 'error', message: 'Connection limit reached' }; + } + this.addSocketId(userId, client.id); client.join(`user:${userId}`); this.logger.log(`User ${userId} subscribed to notifications via socket ${client.id}`);