UNPKG

bb-inspired

Version:

Core library for BB-inspired NestJS backend

488 lines 19.3 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var WebsocketGateway_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.WebsocketGateway = exports.ChannelPermission = void 0; const websockets_1 = require("@nestjs/websockets"); const socket_io_1 = require("socket.io"); const jwt_service_1 = require("../auth/jwt.service"); const logger_1 = require("../../utils/logger"); const common_1 = require("@nestjs/common"); const websocket_rate_limiter_1 = require("./websocket.rate-limiter"); var ChannelPermission; (function (ChannelPermission) { ChannelPermission["READ"] = "read"; ChannelPermission["WRITE"] = "write"; ChannelPermission["ADMIN"] = "admin"; })(ChannelPermission || (exports.ChannelPermission = ChannelPermission = {})); let WebsocketGateway = WebsocketGateway_1 = class WebsocketGateway { constructor(jwtService, rateLimiter) { this.jwtService = jwtService; this.rateLimiter = rateLimiter; this.logger = new logger_1.AppLogger(WebsocketGateway_1.name); this.connectedClients = new Map(); this.channels = new Map(); this.channelAcls = new Map(); this.lastPingTimestamps = new Map(); this.serverStartTime = Date.now(); this.registerChannel({ name: 'system', description: 'System notifications channel', allowPublicSubscribe: false, allowPublicPublish: false, allowAnonymous: false, requiredRoles: ['admin'], }); this.registerChannel({ name: 'notifications', description: 'General notifications channel', allowPublicSubscribe: true, allowPublicPublish: false, allowAnonymous: false, }); } afterInit(server) { this.logger.log('WebSocket Gateway initialized'); } async handleConnection(client, ...args) { var _a; try { if (this.rateLimiter && !this.rateLimiter.handleConnection(client)) { client.disconnect(); return; } const token = client.handshake.auth.token || ((_a = client.handshake.headers.authorization) === null || _a === void 0 ? void 0 : _a.split(' ')[1]); if (!token) { this.logger.warn('Client attempted to connect without a token'); client.disconnect(); return; } const decoded = await this.jwtService.verifyWsToken(token); client.data.user = { id: decoded.sub, connectionId: decoded.connectionId || client.id, roles: decoded.roles || [], permissions: decoded.permissions || [], }; this.connectedClients.set(client.id, client); this.logger.verbose(`Client connected: ${client.id} (User: ${decoded.sub})`); client.emit('connection_established', { connectionId: client.id, message: 'Successfully connected to WebSocket server' }); this.autoJoinAuthorizedChannels(client); } catch (error) { this.logger.error(`WebSocket authentication failed: ${error.message}`); client.disconnect(); } } handleDisconnect(client) { if (this.rateLimiter) { this.rateLimiter.handleDisconnect(client); } this.connectedClients.delete(client.id); this.logger.verbose(`Client disconnected: ${client.id}`); } async handleAuthenticate(client, data) { try { if (!data.token) { throw new Error('Authentication token is required'); } const payload = await this.jwtService.verifyWsToken(data.token); client.data.user = { id: payload.sub, connectionId: payload.connectionId || client.id, roles: payload.roles || [], permissions: payload.permissions || [], }; this.autoJoinAuthorizedChannels(client); return { event: 'authenticated', data: { success: true, userId: payload.sub } }; } catch (error) { this.logger.error(`Authentication failed: ${error.message}`); return { event: 'authenticated', data: { success: false, error: 'Authentication failed' } }; } } handleSubscribe(client, data) { if (this.rateLimiter && !this.rateLimiter.handleMessage(client)) { return { event: 'error', data: { message: 'Rate limit exceeded', code: 'RATE_LIMIT_EXCEEDED' } }; } if (this.rateLimiter && !this.rateLimiter.handleSubscription(client, data.channel)) { return { event: 'error', data: { message: 'Subscription limit exceeded', channel: data.channel, code: 'SUBSCRIPTION_LIMIT_EXCEEDED' } }; } if (!client.data.user) { return { event: 'error', data: { message: 'Not authenticated' } }; } const channelName = data.channel; if (!this.canSubscribeToChannel(client, channelName)) { return { event: 'error', data: { message: `Unauthorized to subscribe to channel: ${channelName}`, channel: channelName } }; } client.join(channelName); this.logger.verbose(`Client ${client.id} subscribed to ${channelName}`); client.emit(`${channelName}:welcome`, { message: `Welcome to channel ${channelName}`, timestamp: new Date().toISOString() }); return { event: 'subscribed', data: { channel: channelName, success: true } }; } handleUnsubscribe(client, data) { if (this.rateLimiter && !this.rateLimiter.handleMessage(client)) { return { event: 'error', data: { message: 'Rate limit exceeded', code: 'RATE_LIMIT_EXCEEDED' } }; } if (this.rateLimiter) { this.rateLimiter.handleUnsubscription(client, data.channel); } client.leave(data.channel); this.logger.verbose(`Client ${client.id} unsubscribed from ${data.channel}`); return { event: 'unsubscribed', data: { channel: data.channel, success: true } }; } handlePublish(client, data) { if (this.rateLimiter && !this.rateLimiter.handleMessage(client)) { return { event: 'error', data: { message: 'Rate limit exceeded', code: 'RATE_LIMIT_EXCEEDED' } }; } if (!client.data.user) { return { event: 'error', data: { message: 'Not authenticated' } }; } const { channel, event, payload } = data; if (!this.canPublishToChannel(client, channel)) { return { event: 'error', data: { message: `Unauthorized to publish to channel: ${channel}`, channel } }; } this.server.to(channel).emit(event, { ...payload, timestamp: new Date().toISOString(), publisher: client.data.user.id }); this.logger.verbose(`Client ${client.id} published to ${channel}: ${event}`); return { event: 'published', data: { channel, event, success: true } }; } registerChannel(channelConfig) { if (this.channels.has(channelConfig.name)) { this.logger.warn(`Channel ${channelConfig.name} already exists`); return false; } this.channels.set(channelConfig.name, channelConfig); this.channelAcls.set(channelConfig.name, new Map()); this.logger.verbose(`Registered channel: ${channelConfig.name}`); return true; } unregisterChannel(channelName) { if (!this.channels.has(channelName)) { return false; } this.server.in(channelName).socketsLeave(channelName); this.channels.delete(channelName); this.channelAcls.delete(channelName); this.logger.verbose(`Unregistered channel: ${channelName}`); return true; } grantChannelPermission(channelName, userId, permission) { if (!this.channels.has(channelName)) { this.logger.warn(`Cannot grant permission for non-existent channel: ${channelName}`); return false; } const channelAcl = this.channelAcls.get(channelName); channelAcl.set(String(userId), permission); this.logger.verbose(`Granted ${permission} permission to user ${userId} for channel ${channelName}`); this.notifyUserOfPermissionChange(userId, channelName, permission); return true; } revokeChannelAccess(channelName, userId) { if (!this.channels.has(channelName)) { return false; } const channelAcl = this.channelAcls.get(channelName); const userIdStr = String(userId); if (!channelAcl.has(userIdStr)) { return false; } channelAcl.delete(userIdStr); this.disconnectUserFromChannel(userId, channelName); this.logger.verbose(`Revoked access for user ${userId} from channel ${channelName}`); return true; } broadcastToChannel(channel, event, data) { this.server.to(channel).emit(event, { ...data, timestamp: new Date().toISOString(), channel }); this.logger.verbose(`Broadcast to channel ${channel}: ${event}`); } sendToClient(clientId, event, data) { const client = this.connectedClients.get(clientId); if (!client) { this.logger.warn(`Client not found: ${clientId}`); return false; } client.emit(event, { ...data, timestamp: new Date().toISOString() }); this.logger.verbose(`Message sent to client ${clientId}: ${event}`); return true; } autoJoinAuthorizedChannels(client) { if (!client.data.user) { return; } for (const [channelName, config] of this.channels.entries()) { if (this.canSubscribeToChannel(client, channelName)) { client.join(channelName); client.emit('auto_subscribed', { channel: channelName }); this.logger.verbose(`Auto-subscribed client ${client.id} to channel ${channelName}`); } } } canSubscribeToChannel(client, channelName) { const user = client.data.user; if (!user) { return false; } const channel = this.channels.get(channelName); if (!channel) { return false; } const channelAcl = this.channelAcls.get(channelName); if (channelAcl.has(String(user.id))) { return true; } if (channel.allowAnonymous) { return true; } if (channel.allowPublicSubscribe) { return true; } if (channel.requiredRoles && channel.requiredRoles.length > 0) { const userRoles = user.roles || []; if (channel.requiredRoles.some(role => userRoles.includes(role))) { return true; } } if (channel.requiredPermissions && channel.requiredPermissions.length > 0) { const userPermissions = user.permissions || []; if (channel.requiredPermissions.some(perm => userPermissions.includes(perm))) { return true; } } return false; } canPublishToChannel(client, channelName) { const user = client.data.user; if (!user) { return false; } const channel = this.channels.get(channelName); if (!channel) { return false; } const channelAcl = this.channelAcls.get(channelName); const userPermission = channelAcl.get(String(user.id)); if (userPermission === ChannelPermission.WRITE || userPermission === ChannelPermission.ADMIN) { return true; } if (channel.allowPublicPublish) { return true; } if (channel.requiredRoles && channel.requiredRoles.length > 0) { const userRoles = user.roles || []; if (channel.requiredRoles.some(role => userRoles.includes(role))) { return true; } } return false; } notifyUserOfPermissionChange(userId, channelName, permission) { for (const [clientId, client] of this.connectedClients.entries()) { if (client.data.user && String(client.data.user.id) === String(userId)) { client.emit('permission_changed', { channel: channelName, permission, timestamp: new Date().toISOString() }); if (permission !== null) { client.join(channelName); client.emit(`${channelName}:welcome`, { message: `Welcome to channel ${channelName}`, timestamp: new Date().toISOString() }); } } } } disconnectUserFromChannel(userId, channelName) { for (const [clientId, client] of this.connectedClients.entries()) { if (client.data.user && String(client.data.user.id) === String(userId)) { client.leave(channelName); client.emit('channel_access_revoked', { channel: channelName, timestamp: new Date().toISOString() }); } } } getServerStatus() { return { running: !!this.server, uptime: Date.now() - this.serverStartTime }; } getActiveConnectionsCount() { return this.connectedClients.size; } getActiveChannelsCount() { return this.channels.size; } pingAllClients() { const timestamp = Date.now(); let count = 0; for (const [clientId, client] of this.connectedClients.entries()) { this.lastPingTimestamps.set(clientId, timestamp); client.emit('ping', { timestamp }); count++; } return count; } handlePong(client, timestamp) { const originalTimestamp = this.lastPingTimestamps.get(client.id); if (originalTimestamp && timestamp === originalTimestamp) { const latency = Date.now() - originalTimestamp; this.server.emit('ws:metrics:latency', { clientId: client.id, latency }); this.lastPingTimestamps.delete(client.id); } } getChannelStats() { var _a, _b; const result = new Map(); for (const [channelName, _] of this.channels.entries()) { const roomSize = ((_a = this.server.sockets.adapter.rooms.get(channelName)) === null || _a === void 0 ? void 0 : _a.size) || 0; result.set(channelName, { subscribers: roomSize, acls: ((_b = this.channelAcls.get(channelName)) === null || _b === void 0 ? void 0 : _b.size) || 0 }); } return result; } handleClientPong(client, data) { this.handlePong(client, data.timestamp); } }; exports.WebsocketGateway = WebsocketGateway; __decorate([ (0, websockets_1.WebSocketServer)(), __metadata("design:type", socket_io_1.Server) ], WebsocketGateway.prototype, "server", void 0); __decorate([ (0, websockets_1.SubscribeMessage)('authenticate'), __param(0, (0, websockets_1.ConnectedSocket)()), __param(1, (0, websockets_1.MessageBody)()), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, Object]), __metadata("design:returntype", Promise) ], WebsocketGateway.prototype, "handleAuthenticate", null); __decorate([ (0, websockets_1.SubscribeMessage)('subscribe'), __param(0, (0, websockets_1.ConnectedSocket)()), __param(1, (0, websockets_1.MessageBody)()), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, Object]), __metadata("design:returntype", Object) ], WebsocketGateway.prototype, "handleSubscribe", null); __decorate([ (0, websockets_1.SubscribeMessage)('unsubscribe'), __param(0, (0, websockets_1.ConnectedSocket)()), __param(1, (0, websockets_1.MessageBody)()), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, Object]), __metadata("design:returntype", Object) ], WebsocketGateway.prototype, "handleUnsubscribe", null); __decorate([ (0, websockets_1.SubscribeMessage)('publish'), __param(0, (0, websockets_1.ConnectedSocket)()), __param(1, (0, websockets_1.MessageBody)()), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, Object]), __metadata("design:returntype", Object) ], WebsocketGateway.prototype, "handlePublish", null); __decorate([ (0, websockets_1.SubscribeMessage)('pong'), __param(0, (0, websockets_1.ConnectedSocket)()), __param(1, (0, websockets_1.MessageBody)()), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, Object]), __metadata("design:returntype", void 0) ], WebsocketGateway.prototype, "handleClientPong", null); exports.WebsocketGateway = WebsocketGateway = WebsocketGateway_1 = __decorate([ (0, websockets_1.WebSocketGateway)({ cors: { origin: '*', }, }), __param(1, (0, common_1.Optional)()), __metadata("design:paramtypes", [jwt_service_1.JwtService, websocket_rate_limiter_1.WebsocketRateLimiter]) ], WebsocketGateway); //# sourceMappingURL=websocket.gateway.js.map