import { forwardRef, Inject, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { ConnectedSocket, MessageBody, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer, } from "@nestjs/websockets"; import { getIronSession } from "iron-session"; import { Server, Socket } from "socket.io"; import { getSessionOptions, SessionData } from "../auth/session.config"; import { JwtService } from "../crypto/services/jwt.service"; import { UsersService } from "../users/users.service"; @WebSocketGateway({ transports: ["websocket"], cors: { origin: ( origin: string, callback: (err: Error | null, allow?: boolean) => void, ) => { // Autoriser si pas d'origine (ex: app mobile ou serveur à serveur) // ou si on est en développement local if ( !origin || origin.includes("localhost") || origin.includes("127.0.0.1") ) { callback(null, true); return; } // En production, on peut restreindre via une variable d'environnement const domainName = process.env.CORS_DOMAIN_NAME; if (!domainName || domainName === "*") { callback(null, true); return; } const allowedOrigins = domainName.split(",").map((o) => o.trim()); if (allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error("Not allowed by CORS")); } }, credentials: true, methods: ["GET", "POST"], }, }) export class EventsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server!: Server; private readonly logger = new Logger(EventsGateway.name); private readonly onlineUsers = new Map>(); // userId -> Set of socketIds constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, @Inject(forwardRef(() => UsersService)) private readonly usersService: UsersService, ) {} afterInit(_server: Server) { this.logger.log("WebSocket Gateway initialized"); } async handleConnection(client: Socket) { try { // Simuler un objet Request/Response pour iron-session const req: any = { headers: client.handshake.headers, }; const res: any = { setHeader: () => {}, getHeader: () => {}, }; const session = await getIronSession( req, res, getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), ); if (!session.accessToken) { this.logger.warn(`Client ${client.id} unauthorized connection`); // Permettre les connexions anonymes pour voir les commentaires en temps réel ? // Pour l'instant on déconnecte car le système actuel semble exiger l'auth client.disconnect(); return; } const payload = await this.jwtService.verifyJwt(session.accessToken); if (!payload.sub) { throw new Error("Invalid token payload: missing sub"); } client.data.user = payload; // Rejoindre une room personnelle pour les notifications client.join(`user:${payload.sub}`); // Gérer le statut en ligne const userId = payload.sub as string; if (!this.onlineUsers.has(userId)) { this.onlineUsers.set(userId, new Set()); // Vérifier les préférences de l'utilisateur const user = await this.usersService.findOne(userId); if (user?.showOnlineStatus) { this.broadcastStatus(userId, "online"); } } this.onlineUsers.get(userId)?.add(client.id); this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`); } catch (error) { this.logger.error(`Connection error for client ${client.id}: ${error}`); client.disconnect(); } } async handleDisconnect(client: Socket) { const userId = client.data.user?.sub; if (userId && this.onlineUsers.has(userId)) { const sockets = this.onlineUsers.get(userId); sockets?.delete(client.id); if (sockets?.size === 0) { this.onlineUsers.delete(userId); const user = await this.usersService.findOne(userId); if (user?.showOnlineStatus) { this.broadcastStatus(userId, "offline"); } } } this.logger.log(`Client disconnected: ${client.id}`); } broadcastStatus(userId: string, status: "online" | "offline") { this.server.emit("user_status", { userId, status }); } isUserOnline(userId: string): boolean { return this.onlineUsers.has(userId); } @SubscribeMessage("join_content") handleJoinContent( @ConnectedSocket() client: Socket, @MessageBody() contentId: string, ) { client.join(`content:${contentId}`); this.logger.log(`Client ${client.id} joined content room: ${contentId}`); } @SubscribeMessage("leave_content") handleLeaveContent( @ConnectedSocket() client: Socket, @MessageBody() contentId: string, ) { client.leave(`content:${contentId}`); this.logger.log(`Client ${client.id} left content room: ${contentId}`); } @SubscribeMessage("typing") async handleTyping( @ConnectedSocket() client: Socket, @MessageBody() data: { recipientId: string; isTyping: boolean }, ) { const userId = client.data.user?.sub; if (!userId) return; // Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing" // ou si on considère que typing est une interaction directe qui outrepasse le statut. // Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat. // Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier. const user = await this.usersService.findOne(userId); if (!user?.showOnlineStatus) return; this.server.to(`user:${data.recipientId}`).emit("user_typing", { userId, isTyping: data.isTyping, }); } @SubscribeMessage("check_status") async handleCheckStatus( @ConnectedSocket() _client: Socket, @MessageBody() userId: string, ) { const isOnline = this.onlineUsers.has(userId); if (!isOnline) return { userId, status: "offline" }; const user = await this.usersService.findOne(userId); if (!user?.showOnlineStatus) return { userId, status: "offline" }; return { userId, status: "online", }; } // Méthode utilitaire pour envoyer des messages à un utilisateur spécifique sendToUser(userId: string, event: string, data: any) { this.server.to(`user:${userId}`).emit(event, data); } sendToContent(contentId: string, event: string, data: any) { this.server.to(`content:${contentId}`).emit(event, data); } }