diff --git a/backend/chatServer/src/chat/chat.error.ts b/backend/chatServer/src/chat/chat.error.ts new file mode 100644 index 00000000..3ce07cb5 --- /dev/null +++ b/backend/chatServer/src/chat/chat.error.ts @@ -0,0 +1,64 @@ +import { HttpStatus } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; + +class ChatException extends WsException { + statusCode: number; + constructor({ statusCode, message } : ChatError , public roomId?: string) { + super({ statusCode, message, roomId }); + this.statusCode = statusCode; + } + + getError() { + return { + statusCode: this.statusCode, + msg: this.message, + roomId: this.roomId || null, + }; + } +} + +interface ChatError { + statusCode: number; + message: string; +} + +const CHATTING_SOCKET_ERROR = { + ROOM_EMPTY: { + statusCode: HttpStatus.BAD_REQUEST, + message: '유저가 참여하고 있는 채팅방이 없습니다.' + }, + + ROOM_EXISTED: { + statusCode: HttpStatus.BAD_REQUEST, + message: '이미 존재하는 방입니다.' + }, + + INVALID_USER: { + statusCode: HttpStatus.UNAUTHORIZED, + message: '유효하지 않는 유저입니다.' + }, + + UNAUTHORIZED: { + statusCode: HttpStatus.UNAUTHORIZED, + message: '해당 명령에 대한 권한이 없습니다.' + }, + + QUESTION_EMPTY: { + statusCode: HttpStatus.BAD_REQUEST, + message: '유효하지 않은 질문입니다.' + }, + + BAN_USER: { + statusCode: HttpStatus.FORBIDDEN, + message: '호스트에 의해 밴 당한 유저입니다.' + }, + + MSG_TOO_LONG:{ + statusCode: HttpStatus.NOT_ACCEPTABLE, + message: '메세지의 내용이 없거나, 길이가 150자를 초과했습니다.' + } + + +}; +export { CHATTING_SOCKET_ERROR, ChatException }; + diff --git a/backend/chatServer/src/chat/chat.gateway.ts b/backend/chatServer/src/chat/chat.gateway.ts index 7966bd42..ceaadb69 100644 --- a/backend/chatServer/src/chat/chat.gateway.ts +++ b/backend/chatServer/src/chat/chat.gateway.ts @@ -6,21 +6,21 @@ import { OnGatewayConnection, OnGatewayDisconnect, MessageBody, - ConnectedSocket, + ConnectedSocket } from '@nestjs/websockets'; import { UseGuards } from '@nestjs/common'; import { Server, Socket } from 'socket.io'; import { - CHATTING_SOCKET_DEFAULT_EVENT, - CHATTING_SOCKET_RECEIVE_EVENT, CHATTING_SOCKET_SEND_EVENT + CHATTING_SOCKET_DEFAULT_EVENT, CHATTING_SOCKET_RECEIVE_EVENT, CHATTING_SOCKET_SEND_EVENT } from '../event/constants'; import { + BanUserIncomingMessageDto, NormalIncomingMessageDto, NoticeIncomingMessageDto, QuestionDoneIncomingMessageDto, QuestionIncomingMessageDto } from '../event/dto/IncomingMessage.dto'; import { JoiningRoomDto } from '../event/dto/JoiningRoom.dto'; import { RoomService } from '../room/room.service'; import { createAdapter } from '@socket.io/redis-adapter'; -import { HostGuard, MessageGuard } from './chat.guard'; +import { BlacklistGuard, HostGuard, MessageGuard } from './chat.guard'; import { LeavingRoomDto } from '../event/dto/LeavingRoom.dto'; import { NormalOutgoingMessageDto, @@ -28,8 +28,13 @@ import { QuestionOutgoingMessageDto } from '../event/dto/OutgoingMessage.dto'; import { QuestionDto } from '../event/dto/Question.dto'; +import { ChatException, CHATTING_SOCKET_ERROR } from './chat.error'; -@WebSocketGateway({ cors: true }) +@WebSocketGateway({ + cors: true, + pingInterval: 30000, + pingTimeout: 10000, +}) export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { constructor(private roomService: RoomService) {}; @@ -46,7 +51,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa async handleConnection(client: Socket) { console.log(`Client connected: ${client.id}`); - const user = await this.roomService.createUser(client.id); + const user = await this.roomService.createUser(client); console.log(user); /* @@ -69,6 +74,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // 특정 방에 참여하기 위한 메서드 + @UseGuards(BlacklistGuard) @SubscribeMessage(CHATTING_SOCKET_DEFAULT_EVENT.JOIN_ROOM) async handleJoinRoom(client: Socket, payload: JoiningRoomDto) { const { roomId, userId } = payload; @@ -93,17 +99,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // 방에 NORMAL 메시지를 보내기 위한 메서드 - @UseGuards(MessageGuard) + @UseGuards(MessageGuard, BlacklistGuard) @SubscribeMessage(CHATTING_SOCKET_SEND_EVENT.NORMAL) async handleNormalMessage(@ConnectedSocket() client: Socket, @MessageBody() payload: NormalIncomingMessageDto) { const { roomId, userId, msg } = payload; const user = await this.roomService.getUserByClientId(client.id); const normalOutgoingMessage: Omit = { roomId, - ...user, + nickname: user.nickname, + color: user.color, + entryTime: user.entryTime, msg, msgTime: new Date().toISOString(), - msgType: 'normal' + msgType: 'normal', + socketId: client.id }; console.log('Normal Message Come In: ', normalOutgoingMessage); const hostId = await this.roomService.getHostOfRoom(roomId); @@ -121,18 +130,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // 방에 QUESTION 메시지를 보내기 위한 메서드 - @UseGuards(MessageGuard) + @UseGuards(MessageGuard,BlacklistGuard) @SubscribeMessage(CHATTING_SOCKET_SEND_EVENT.QUESTION) async handleQuestionMessage(@ConnectedSocket() client: Socket, @MessageBody() payload: QuestionIncomingMessageDto) { const { roomId, msg } = payload; const user = await this.roomService.getUserByClientId(client.id); const questionWithoutId: Omit = { roomId, - ...user, + nickname: user.nickname, + color: user.color, + entryTime: user.entryTime, msg, msgTime: new Date().toISOString(), msgType: 'question', - questionDone: false + questionDone: false, + socketId: client.id }; const question: QuestionOutgoingMessageDto = await this.roomService.addQuestion(roomId, questionWithoutId); @@ -150,19 +162,34 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // 방에 NOTICE 메시지를 보내기 위한 메서드 - @UseGuards(MessageGuard) - @UseGuards(HostGuard) + @UseGuards(MessageGuard, HostGuard) @SubscribeMessage(CHATTING_SOCKET_SEND_EVENT.NOTICE) async handleNoticeMessage(@ConnectedSocket() client: Socket, @MessageBody() payload: NoticeIncomingMessageDto) { const { roomId, msg } = payload; const user = await this.roomService.getUserByClientId(client.id); const noticeOutgoingMessage: NoticeOutgoingMessageDto = { roomId, - ...user, + nickname: user.nickname, + color: user.color, + entryTime: user.entryTime, msg, msgTime: new Date().toISOString(), msgType: 'notice' }; this.server.to(roomId).emit(CHATTING_SOCKET_RECEIVE_EVENT.NOTICE, noticeOutgoingMessage); } + + @UseGuards(HostGuard) + @SubscribeMessage(CHATTING_SOCKET_DEFAULT_EVENT.BAN_USER) + async handleBanUserMessage(@ConnectedSocket() client: Socket, @MessageBody() payload: BanUserIncomingMessageDto) { + const { roomId, socketId } = payload; + const banUser = await this.roomService.getUserByClientId(socketId); + console.log('banUSer = ', banUser); + if(!banUser) throw new ChatException(CHATTING_SOCKET_ERROR.INVALID_USER, roomId); + const { address, userAgent } = banUser; + if(!userAgent) throw new ChatException(CHATTING_SOCKET_ERROR.INVALID_USER, roomId); + + await this.roomService.addUserToBlacklist(roomId, address, userAgent); + console.log(await this.roomService.getUserBlacklist(roomId, address)); + } } diff --git a/backend/chatServer/src/chat/chat.guard.ts b/backend/chatServer/src/chat/chat.guard.ts index 270defc3..0a66e366 100644 --- a/backend/chatServer/src/chat/chat.guard.ts +++ b/backend/chatServer/src/chat/chat.guard.ts @@ -1,5 +1,8 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { RoomService } from '../room/room.service'; +import { Socket } from 'socket.io'; + +import { ChatException, CHATTING_SOCKET_ERROR } from './chat.error'; @Injectable() export class MessageGuard implements CanActivate { @@ -7,7 +10,8 @@ export class MessageGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const payload = context.switchToWs().getData(); const { msg } = payload; - return !!msg && msg.length <= 150; + if(!!msg && msg.length <= 150) return true; + throw new ChatException(CHATTING_SOCKET_ERROR.MSG_TOO_LONG); } } @@ -19,6 +23,35 @@ export class HostGuard implements CanActivate { const { roomId, userId } = payload; const hostId = await this.roomService.getHostOfRoom(roomId); console.log('hostGuard:', hostId, userId); - return hostId === userId; + if (hostId === userId) return true; + throw new ChatException(CHATTING_SOCKET_ERROR.UNAUTHORIZED, roomId); + } +} + +@Injectable() +export class BlacklistGuard implements CanActivate { + constructor(private roomService: RoomService) {}; + async canActivate(context: ExecutionContext) { + const payload = context.switchToWs().getData(); + const { roomId } = payload; + + const client: Socket = context.switchToWs().getClient(); + const address = client.handshake.address.replaceAll('::ffff:', ''); + const userAgent = client.handshake.headers['user-agent']; + + if(!userAgent) throw new ChatException(CHATTING_SOCKET_ERROR.INVALID_USER, roomId); + const isValidUser = await this.whenJoinRoom(roomId, address, userAgent); + + if(!isValidUser) throw new ChatException(CHATTING_SOCKET_ERROR.BAN_USER, roomId); + return true; + } + + async whenJoinRoom(roomId: string, address: string, userAgent: string) { + console.log(roomId, address, userAgent); + const blacklistInRoom = await this.roomService.getUserBlacklist(roomId, address); + console.log(blacklistInRoom); + const isInBlacklistUser = blacklistInRoom.some((bannedUserAgent) => bannedUserAgent === userAgent); + console.log('blacklistInRoom:', isInBlacklistUser); + return !isInBlacklistUser; } } diff --git a/backend/chatServer/src/chat/chat.module.ts b/backend/chatServer/src/chat/chat.module.ts index 51629715..bd285e1f 100644 --- a/backend/chatServer/src/chat/chat.module.ts +++ b/backend/chatServer/src/chat/chat.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { ChatGateway } from './chat.gateway'; import { RoomModule } from '../room/room.module'; -import { MessageGuard } from './chat.guard'; +import { BlacklistGuard, HostGuard, MessageGuard } from './chat.guard'; @Module({ imports: [RoomModule], - providers: [ChatGateway, MessageGuard], + providers: [ChatGateway, MessageGuard, BlacklistGuard, HostGuard], }) export class ChatModule {} diff --git a/backend/chatServer/src/event/constants.ts b/backend/chatServer/src/event/constants.ts index 2ce8d053..ce8fd0c6 100644 --- a/backend/chatServer/src/event/constants.ts +++ b/backend/chatServer/src/event/constants.ts @@ -1,8 +1,7 @@ -import { HttpStatus } from '@nestjs/common'; - const CHATTING_SOCKET_DEFAULT_EVENT = { JOIN_ROOM: 'join_room', LEAVE_ROOM: 'leave_room', + BAN_USER: 'ban_user', }; const CHATTING_SOCKET_RECEIVE_EVENT = { @@ -20,28 +19,4 @@ const CHATTING_SOCKET_SEND_EVENT = { NOTICE: 'send_notice' }; -const CHATTING_SOCKET_ERROR = { - ROOM_EMPTY : { - statusCode: HttpStatus.BAD_REQUEST, - message: '유저가 참여하고 있는 채팅방이 없습니다.' - }, - - ROOM_EXISTED: { - statusCode: HttpStatus.BAD_REQUEST, - message: '이미 존재하는 방입니다.' - }, - - INVALID_USER: { - statusCode: HttpStatus.UNAUTHORIZED, - message: '유효하지 않는 유저입니다.' - }, - - QUESTION_EMPTY: { - statusCode: HttpStatus.BAD_REQUEST, - message: '유효하지 않은 질문입니다.' - }, - - -}; - -export { CHATTING_SOCKET_DEFAULT_EVENT, CHATTING_SOCKET_SEND_EVENT, CHATTING_SOCKET_RECEIVE_EVENT, CHATTING_SOCKET_ERROR}; +export { CHATTING_SOCKET_DEFAULT_EVENT, CHATTING_SOCKET_SEND_EVENT, CHATTING_SOCKET_RECEIVE_EVENT}; diff --git a/backend/chatServer/src/event/dto/IncomingMessage.dto.ts b/backend/chatServer/src/event/dto/IncomingMessage.dto.ts index 3dd9b237..de5d071a 100644 --- a/backend/chatServer/src/event/dto/IncomingMessage.dto.ts +++ b/backend/chatServer/src/event/dto/IncomingMessage.dto.ts @@ -21,6 +21,11 @@ class NoticeIncomingMessageDto extends DefaultIncomingMessageDto { msg: string = ''; } +class BanUserIncomingMessageDto extends DefaultIncomingMessageDto { + userId: string = ''; + socketId: string = ''; +} + export { @@ -28,5 +33,6 @@ export { QuestionIncomingMessageDto, QuestionDoneIncomingMessageDto, DefaultIncomingMessageDto, - NoticeIncomingMessageDto + NoticeIncomingMessageDto, + BanUserIncomingMessageDto }; diff --git a/backend/chatServer/src/event/dto/OutgoingMessage.dto.ts b/backend/chatServer/src/event/dto/OutgoingMessage.dto.ts index 389ebad1..f77eac50 100644 --- a/backend/chatServer/src/event/dto/OutgoingMessage.dto.ts +++ b/backend/chatServer/src/event/dto/OutgoingMessage.dto.ts @@ -5,6 +5,7 @@ class DefaultOutgoingMessageDto { roomId: string = ''; nickname: string = ''; color: string = ''; + entryTime: string = ''; msgTime: string = new Date().toISOString(); } @@ -12,6 +13,7 @@ class NormalOutgoingMessageDto extends DefaultOutgoingMessageDto { msg: string = ''; msgType: OutgoingMessageType = 'normal'; owner: WhoAmI = 'user'; + socketId: string = ''; } class QuestionOutgoingMessageDto extends DefaultOutgoingMessageDto { @@ -19,9 +21,15 @@ class QuestionOutgoingMessageDto extends DefaultOutgoingMessageDto { questionId: number = -1; questionDone: boolean = false; msgType: OutgoingMessageType = 'question'; + socketId: string = ''; } -class QuestionDoneOutgoingMessageDto extends QuestionOutgoingMessageDto {} +class QuestionDoneOutgoingMessageDto extends DefaultOutgoingMessageDto { + msg: string = ''; + questionId: number = -1; + questionDone: boolean = false; + msgType: OutgoingMessageType = 'question'; +} class NoticeOutgoingMessageDto extends DefaultOutgoingMessageDto { msg: string = ''; diff --git a/backend/chatServer/src/event/dto/Question.dto.ts b/backend/chatServer/src/event/dto/Question.dto.ts index 0df4b9d4..67ab8788 100644 --- a/backend/chatServer/src/event/dto/Question.dto.ts +++ b/backend/chatServer/src/event/dto/Question.dto.ts @@ -4,10 +4,12 @@ class QuestionDto { roomId: string = ''; nickname: string = ''; color: string = ''; + entryTime: string = ''; msg: string = ''; msgTime: string = new Date().toISOString(); msgType: OutgoingMessageType = 'question'; questionId: number = -1; + socketId: string = ''; questionDone: boolean = false; } diff --git a/backend/chatServer/src/room/room.repository.ts b/backend/chatServer/src/room/room.repository.ts index 6bb90b68..61c78e65 100644 --- a/backend/chatServer/src/room/room.repository.ts +++ b/backend/chatServer/src/room/room.repository.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; import { Cluster } from 'ioredis'; -import { CHATTING_SOCKET_ERROR } from '../event/constants'; -import { WsException } from '@nestjs/websockets'; import { QuestionDto } from '../event/dto/Question.dto'; +import { ChatException, CHATTING_SOCKET_ERROR } from '../chat/chat.error'; +import { User } from './user.interface'; + +type USER_AGENT = string; @Injectable() export class RoomRepository { @@ -10,11 +12,17 @@ export class RoomRepository { roomIdPrefix = 'room:'; questionPrefix = 'question'; questionIdPrefix = 'id'; + blacklistPrefix = 'blacklist'; + userPrefix = 'user'; injectClient(redisClient: Cluster){ this.redisClient = redisClient; } + private getUserStringWithPrefix(clientId: string){ + return `${this.userPrefix}:${clientId}`; + } + private getRoomStringWithPrefix(roomId: string) { return `${this.roomIdPrefix}${roomId}`; } @@ -27,21 +35,59 @@ export class RoomRepository { return `${this.getRoomStringWithPrefix(roomId)}-${this.questionPrefix}-${this.questionIdPrefix}`; } + private getUserBlacklistStringWithPrefix(address:string){ + return `${this.blacklistPrefix}:${address}`; + } + + private getUserBlacklistInRoomWithPrefix(roomId:string, address:string){ + return `${this.getRoomStringWithPrefix(roomId)}-${this.getUserBlacklistStringWithPrefix(address)}`; + } + private async lindex(key: string, index: number){ const result = await this.redisClient.lindex(key, index); - if(result) return JSON.parse(result) as T; - return undefined; + if(!result) return undefined; + try { + return JSON.parse(result) as T; + } catch { + return result as T; + } } private async lrange(key: string, start: number, end: number){ const result = await this.redisClient.lrange(key, start, end); - return result.map((r) => JSON.parse(r)) as T; + if(!result) return undefined; + const arrayT = result.map((r) => { + try { + return JSON.parse(r); + } catch { + return r; + } + }); + return arrayT as T; } private async getData(key: string) { const result = await this.redisClient.get(key); - if(result) return JSON.parse(result) as T; - return undefined; + if(!result) return undefined; + try { + return JSON.parse(result) as T; + } catch { + return result as T; + } + } + + async createUser(clientId: string, user: User){ + return !! await this.redisClient.set(this.getUserStringWithPrefix(clientId), JSON.stringify(user)); + } + + async getUser(clientId: string) { + const user = await this.getData(this.getUserStringWithPrefix(clientId)); + if(!user) throw new ChatException(CHATTING_SOCKET_ERROR.INVALID_USER); + return user; + } + + async deleteUser(clientId: string){ + return !! await this.redisClient.del(this.getUserStringWithPrefix(clientId)); } async isRoomExisted(roomId: string) { @@ -50,7 +96,7 @@ export class RoomRepository { async getHost(roomId: string) { const hostId = await this.redisClient.get(this.getRoomStringWithPrefix(roomId)); - if(!hostId) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if(!hostId) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); return hostId; } @@ -72,7 +118,7 @@ export class RoomRepository { async markQuestionAsDone(roomId: string, questionId: number): Promise { const question = await this.getQuestion(roomId, questionId); - if(!question) throw new WsException(CHATTING_SOCKET_ERROR.QUESTION_EMPTY); + if(!question) throw new ChatException(CHATTING_SOCKET_ERROR.QUESTION_EMPTY, roomId); question.questionDone = true; this.redisClient.lset(this.getQuestionStringWithPrefix(roomId), questionId, JSON.stringify(question)); return question; @@ -88,7 +134,7 @@ export class RoomRepository { async getQuestion(roomId: string, questionId: number): Promise { const question = await this.lindex>(this.getQuestionStringWithPrefix(roomId), questionId); if(question) return {...question, questionId }; - throw new WsException(CHATTING_SOCKET_ERROR.QUESTION_EMPTY); + throw new ChatException(CHATTING_SOCKET_ERROR.QUESTION_EMPTY, roomId); } async getQuestionId(roomId: string) { @@ -102,4 +148,16 @@ export class RoomRepository { } + async getUserBlacklist(roomId: string, address: string): Promise { + const userBlacklist = await this.lrange(this.getUserBlacklistInRoomWithPrefix(roomId, address), 0, -1); + console.log('blacklist', userBlacklist); + if (!userBlacklist) return []; + return userBlacklist; + } + + async addUserBlacklistToRoom(roomId: string, address: string, userAgent: string){ + console.log(roomId, address, userAgent); + console.log(this.getUserBlacklistInRoomWithPrefix(roomId, address)); + return this.redisClient.rpush(this.getUserBlacklistInRoomWithPrefix(roomId, address), userAgent); + } } diff --git a/backend/chatServer/src/room/room.service.ts b/backend/chatServer/src/room/room.service.ts index 3246d45e..8b0a0553 100644 --- a/backend/chatServer/src/room/room.service.ts +++ b/backend/chatServer/src/room/room.service.ts @@ -1,8 +1,6 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Cluster, Redis } from 'ioredis'; import { createAdapter } from '@socket.io/redis-adapter'; -import { WsException } from '@nestjs/websockets'; -import { CHATTING_SOCKET_ERROR } from '../event/constants'; import { User } from './user.interface'; import { getRandomAdjective, getRandomBrightColor, getRandomNoun } from '../utils/random'; import { RoomRepository } from './room.repository'; @@ -12,6 +10,8 @@ import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { ChatException, CHATTING_SOCKET_ERROR } from '../chat/chat.error'; +import { Socket } from 'socket.io'; // 현재 파일의 URL을 파일 경로로 변환 const __filename = fileURLToPath(import.meta.url); @@ -25,10 +25,13 @@ function createRandomNickname(){ return `${getRandomAdjective()} ${getRandomNoun()}`; } -function createRandomUserInstance(): User { +function createRandomUserInstance(address: string, userAgent: string): User { return { + address, + userAgent, nickname: createRandomNickname(), - color: getRandomBrightColor() + color: getRandomBrightColor(), + entryTime: new Date().toISOString() }; } @@ -36,7 +39,6 @@ function createRandomUserInstance(): User { export class RoomService implements OnModuleInit, OnModuleDestroy { redisAdapter: ReturnType; redisClient: Cluster; - users: Map = new Map(); constructor(private redisRepository: RoomRepository) { this.redisClient = new Redis.Cluster(REDIS_CONFIG); @@ -85,13 +87,13 @@ export class RoomService implements OnModuleInit, OnModuleDestroy { // 방 삭제 async deleteRoom(roomId: string) { const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); await this.redisRepository.deleteRoom(roomId); } async addQuestion(roomId: string, question: Omit){ const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); return await this.redisRepository.addQuestionToRoom(roomId, question); } @@ -102,7 +104,7 @@ export class RoomService implements OnModuleInit, OnModuleDestroy { question: Omit, ): Promise { const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); return await this.redisRepository.addQuestionToRoom(roomId, question); } @@ -110,24 +112,24 @@ export class RoomService implements OnModuleInit, OnModuleDestroy { // 특정 질문 완료 처리 async markQuestionAsDone(roomId: string, questionId: number) { const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); const markedQuestion = await this.redisRepository.markQuestionAsDone(roomId, questionId); - if (!markedQuestion) throw new WsException(CHATTING_SOCKET_ERROR.QUESTION_EMPTY); + if (!markedQuestion) throw new ChatException(CHATTING_SOCKET_ERROR.QUESTION_EMPTY, roomId); return markedQuestion; } // 방에 속한 모든 질문 조회 async getQuestions(roomId: string): Promise { const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); return this.redisRepository.getQuestionsAll(roomId); } async getQuestionsNotDone(roomId: string): Promise { const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); return this.redisRepository.getQuestionsUnmarked(roomId); } @@ -135,35 +137,50 @@ export class RoomService implements OnModuleInit, OnModuleDestroy { // 특정 질문 조회 async getQuestion(roomId: string, questionId: number): Promise { const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); return this.redisRepository.getQuestion(roomId, questionId); } // 유저 생성 - async createUser(clientId: string) { - const newUser = createRandomUserInstance(); - this.users.set(clientId, newUser); + async createUser(socket: Socket) { + const clientId = socket.id; + const address = socket.handshake.address.replaceAll('::ffff:', ''); + const userAgent = socket.handshake.headers['user-agent']; + + if(!address || !userAgent) throw new ChatException(CHATTING_SOCKET_ERROR.INVALID_USER); + + const newUser = createRandomUserInstance(address, userAgent); + const isCreatedDone = await this.redisRepository.createUser(clientId, newUser); + if(!isCreatedDone) throw new ChatException(CHATTING_SOCKET_ERROR.INVALID_USER); + console.log(newUser); return newUser; } // 유저 삭제 async deleteUser(clientId: string) { - const user = this.users.get(clientId); - if (!user) throw new WsException(CHATTING_SOCKET_ERROR.INVALID_USER); - this.users.delete(clientId); - return user; + return await this.redisRepository.deleteUser(clientId); } // 특정 유저 조회 async getUserByClientId(clientId: string) { - const user = this.users.get(clientId); - if (!user) throw new WsException(CHATTING_SOCKET_ERROR.INVALID_USER); + const user = this.redisRepository.getUser(clientId); return user; } async getHostOfRoom(roomId: string) { const roomExists = await this.redisRepository.isRoomExisted(roomId); - if (!roomExists) throw new WsException(CHATTING_SOCKET_ERROR.ROOM_EMPTY); + if (!roomExists) throw new ChatException(CHATTING_SOCKET_ERROR.ROOM_EMPTY, roomId); return await this.redisRepository.getHost(roomId); } + + async getUserBlacklist(roomId: string, address: string) { + const roomExists = await this.redisRepository.isRoomExisted(roomId); + if (!roomExists) return []; + + return await this.redisRepository.getUserBlacklist(roomId, address); + } + + async addUserToBlacklist(roomId: string, address: string, userAgent: string){ + return await this.redisRepository.addUserBlacklistToRoom(roomId, address, userAgent); + } } diff --git a/backend/chatServer/src/room/user.interface.ts b/backend/chatServer/src/room/user.interface.ts index a37d58a8..ad6f82cb 100644 --- a/backend/chatServer/src/room/user.interface.ts +++ b/backend/chatServer/src/room/user.interface.ts @@ -1,4 +1,7 @@ interface User { + address: string; + userAgent: string; + entryTime: string; nickname: string; color: string; } diff --git a/backend/mainServer/src/common/constants.ts b/backend/mainServer/src/common/constants.ts index a920a9c5..cede7f87 100644 --- a/backend/mainServer/src/common/constants.ts +++ b/backend/mainServer/src/common/constants.ts @@ -1,5 +1,5 @@ export const DEFAULT_VALUE = { THUMBNAIL_IMG_URL : 'https://kr.object.ncloudstorage.com/web22/static/liboo_default_thumbnail.png', - NOTICE : '쾌적한 컨퍼런스 환경을 위해 상대방을 존중하는 언어를 사용해 주시길 바랍니다. 모두가 배움과 소통을 즐길 수 있는 문화를 함께 만들기에 동참해주세요.', + NOTICE : '쾌적한 컨퍼런스 환경을 위해 상대방을 존중하는 언어를 사용해 주시길 바랍니다.\n모두가 배움과 소통을 즐길 수 있는 문화를 함께 만들기에 동참해주세요.', HOST_NAME : '방송 진행자', }; \ No newline at end of file diff --git a/backend/mainServer/src/dto/memoryDbDto.ts b/backend/mainServer/src/dto/memoryDbDto.ts index 313c18cd..3083a6b0 100644 --- a/backend/mainServer/src/dto/memoryDbDto.ts +++ b/backend/mainServer/src/dto/memoryDbDto.ts @@ -147,10 +147,10 @@ export class MemoryDbDto { readCount: number = 0; @ApiProperty({ - description: '라이브 우선 순위', + description: '라이브 당시 시청횟수', example: 0, }) - livePr: number = 0; + livePr: number = Math.floor(Math.random() * 1000) + 100; constructor(data?: Partial) { if (data) { diff --git a/backend/mainServer/src/host/host.controller.ts b/backend/mainServer/src/host/host.controller.ts index af27a0e8..0e85cb62 100644 --- a/backend/mainServer/src/host/host.controller.ts +++ b/backend/mainServer/src/host/host.controller.ts @@ -34,6 +34,7 @@ export class HostController { sessionInfo.replay = false; sessionInfo.startDate = new Date(); sessionInfo.streamUrl = `https://kr.object.ncloudstorage.com/web22/live/${sessionInfo.sessionKey}/index.m3u8`; + sessionInfo.liveImageUrl = `https://kr.object.ncloudstorage.com/web22/live/${sessionInfo.sessionKey}/thumbnail.png`; this.memoryDBService.updateBySessionKey(streamKey, sessionInfo); res.status(HttpStatus.OK).json({ 'session-key': sessionInfo.sessionKey }); } catch (error) { diff --git a/backend/mainServer/src/memory-db/memory-db.service.ts b/backend/mainServer/src/memory-db/memory-db.service.ts index 6b015f2e..33a45cdc 100644 --- a/backend/mainServer/src/memory-db/memory-db.service.ts +++ b/backend/mainServer/src/memory-db/memory-db.service.ts @@ -67,8 +67,8 @@ export class MemoryDBService { return getRandomElementsFromArray(liveSession, count); } - getBroadcastInfo(size: number, dtoTransformer: (info: MemoryDbDto) => T, checker: (item: MemoryDbDto) => boolean, appender: number = 0) { - const findSession = this.db.filter(item => checker(item)); + getBroadcastInfo(size: number, dtoTransformer: (info: MemoryDbDto) => T, checker: (item: MemoryDbDto) => boolean, compare: (a: MemoryDbDto, b: MemoryDbDto) => number, appender: number = 0) { + const findSession = this.db.filter(item => checker(item)).sort((a: MemoryDbDto, b: MemoryDbDto) => compare(a, b)); if (findSession.length < size) { const findSessionRev = findSession.reverse().map((info) => dtoTransformer(info)); return [[...findSessionRev], []]; @@ -99,6 +99,14 @@ export class MemoryDBService { return true; } + updateById(id: number, updatedItem: Partial): boolean { + const index = this.db.findIndex(item => Number(item.id) === Number(id)); + if (index === -1) return false; + console.log(this.db[index]); + this.db[index] = new MemoryDbDto({ ...this.db[index], ...updatedItem }); + return true; + } + updateBySessionKey(sessionKey: string, updatedItem: Partial): boolean { const index = this.db.findIndex(item => item.sessionKey === sessionKey); if (index === -1) return false; diff --git a/backend/mainServer/src/mock-data/mock-data.controller.ts b/backend/mainServer/src/mock-data/mock-data.controller.ts index f95e3331..286bbf21 100644 --- a/backend/mainServer/src/mock-data/mock-data.controller.ts +++ b/backend/mainServer/src/mock-data/mock-data.controller.ts @@ -45,6 +45,14 @@ export class MockDataController { this.memoryDbService.create(newData); } + @Post('/update') + @ApiOperation({ summary: 'Delete Session Info', description: '방송 정보를 삭제합니다. (start만 적으면 단일, start, end 범위를 적으면 범위 삭제)' }) + async updateMemodyData(@Query('id') id: number, @Body() newData: MemoryDbDto) { + console.log(id); + console.log(newData); + this.memoryDbService.updateById(id, newData); + } + @Get('/chzzk/switch') @ApiOperation({summary: 'Change Curation Data', description: '메인 랜덤 영상을 치지직 영상으로 대체합니다. (true: mode On, false: mode off 전환 시 기존 치지직 데이터 초기화)'}) async changeCurationData(@Res() res: Response) { diff --git a/backend/mainServer/src/mock-data/mock-data.service.ts b/backend/mainServer/src/mock-data/mock-data.service.ts index 5d53132c..9f1d4e2e 100644 --- a/backend/mainServer/src/mock-data/mock-data.service.ts +++ b/backend/mainServer/src/mock-data/mock-data.service.ts @@ -13,6 +13,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Tech Conference 2024', category: 'Technology', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test1_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test1-live-image.png', tags: ['Conference', 'Tech', '2024'], startDate: new Date('2024-11-21T09:00:00'), endDate: new Date('2024-11-21T11:00:00'), @@ -30,6 +31,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'DAN24', category: 'Art', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test2_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test2-live-image.png', tags: ['Dan', 'Showcase', 'Art'], startDate: new Date('2024-11-21T12:00:00'), endDate: new Date('2024-11-21T14:00:00'), @@ -47,6 +49,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Gaming Tournament Finals', category: 'Gaming', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test3_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test3-live-image.png', tags: ['Gaming', 'Esports', 'Finals'], startDate: new Date('2024-11-21T15:00:00'), endDate: new Date('2024-11-21T18:00:00'), @@ -64,6 +67,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Music Live Show', category: 'Music', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test4_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test4-live-image.png', tags: ['Music', 'Live', 'Concert'], startDate: new Date('2024-11-21T19:00:00'), endDate: new Date('2024-11-21T21:00:00'), @@ -81,6 +85,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Cooking with Pros', category: 'Food', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test5_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test5-live-image.png', tags: ['Cooking', 'Food', 'Recipes'], startDate: new Date('2024-11-22T12:00:00'), endDate: new Date('2024-11-22T14:00:00'), @@ -98,6 +103,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Tech Conference 2024', category: 'Technology', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test6_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test6-live-image.png', tags: ['Tech', 'Conference', 'Innovation'], startDate: new Date('2024-11-22T15:00:00'), endDate: new Date('2024-11-22T18:00:00'), @@ -115,6 +121,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Art Masterclass', category: 'Art', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test7_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test7-live-image.png', tags: ['Art', 'Painting', 'Creative'], startDate: new Date('2024-11-23T10:00:00'), endDate: new Date('2024-11-23T12:00:00'), @@ -132,6 +139,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Fitness Live Session', category: 'Health', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test8_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test8-live-image.png', tags: ['Fitness', 'Health', 'Workout'], startDate: new Date('2024-11-23T16:00:00'), endDate: new Date('2024-11-23T17:00:00'), @@ -149,6 +157,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Travel Vlog Live', category: 'Travel', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test9_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test9-live-image.png', tags: ['Travel', 'Adventure', 'Vlog'], startDate: new Date('2024-11-24T09:00:00'), endDate: new Date('2024-11-24T11:00:00'), @@ -166,6 +175,7 @@ export class MockDataService implements OnModuleInit { liveTitle: 'Replay Title', category: 'Replay Category', defaultThumbnailImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test10_thumbnail.png', + liveImageUrl: 'https://kr.object.ncloudstorage.com/web22/static/test10-live-image.png', tags: ['replay', '다시보기'], startDate: new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago endDate: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago diff --git a/backend/mainServer/src/replay/replay.controller.ts b/backend/mainServer/src/replay/replay.controller.ts index 76288cad..59d004d7 100644 --- a/backend/mainServer/src/replay/replay.controller.ts +++ b/backend/mainServer/src/replay/replay.controller.ts @@ -17,7 +17,12 @@ export class ReplayController { async getLatestReplay(@Res() res: Response) { try { const replayChecker = (item: MemoryDbDto) => { return item.replay && !item.state; }; - const [serchedData, appendData] = this.memoryDBService.getBroadcastInfo(8, memoryDbDtoToReplayVideoDto, replayChecker, 8); + const compare = (a: MemoryDbDto, b: MemoryDbDto) => { + const aTime = a.startDate ? a.startDate.getTime() : 0; + const bTime = b.startDate ? b.startDate.getTime() : 0; + return aTime - bTime; + }; + const [serchedData, appendData] = this.memoryDBService.getBroadcastInfo(8, memoryDbDtoToReplayVideoDto, replayChecker, compare, 8); res.status(HttpStatus.OK).json({info: serchedData, appendInfo: appendData}); } catch (error) { if ((error as { status: number }).status === 400) { @@ -58,4 +63,20 @@ export class ReplayController { } } + @Get('/existence') + @ApiOperation({summary: 'Get replay exited', description: '다시보기에 대한 존재 여부를 반환 받습니다.'}) + async getExistence(@Query('videoId') videoId: string, @Res() res: Response) { + try { + const replaySessions = this.memoryDBService.findAll().filter((info) => info.replay); + if (replaySessions.some((info) => info.sessionKey === videoId)) { + res.status(HttpStatus.OK).json({existed: true}); + } + else { + res.status(HttpStatus.OK).json({existed: false}); + } + } catch (err) { + console.log(err); + res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(); + } + } } diff --git a/backend/mainServer/src/streams/streams.controller.ts b/backend/mainServer/src/streams/streams.controller.ts index 2a84a162..ed043f59 100644 --- a/backend/mainServer/src/streams/streams.controller.ts +++ b/backend/mainServer/src/streams/streams.controller.ts @@ -37,7 +37,12 @@ export class StreamsController { async getLatestSession(@Res() res: Response) { try { const streamChecker = (item: MemoryDbDto) => item.state; - const [serchedData, appendData] = this.memoryDBService.getBroadcastInfo(8, fromLiveSessionDto, streamChecker, 8); + const compare = (a: MemoryDbDto, b: MemoryDbDto) => { + const aTime = a.startDate ? a.startDate.getTime() : 0; + const bTime = b.startDate ? b.startDate.getTime() : 0; + return aTime - bTime; + }; + const [serchedData, appendData] = this.memoryDBService.getBroadcastInfo(8, fromLiveSessionDto, streamChecker, compare, 8); res.status(HttpStatus.OK).json({info: serchedData, appendInfo: appendData}); } catch (error) { if ((error as { status: number }).status === 400) { @@ -85,7 +90,7 @@ export class StreamsController { if (!sessionInfo) { throw new HttpException('Bad Request', HttpStatus.BAD_REQUEST); } - res.status(HttpStatus.OK).json({notice: sessionInfo.notice}); + res.status(HttpStatus.OK).json({notice: sessionInfo.notice, channelName: sessionInfo.channel.channelName}); } catch (error) { if ((error as { status: number }).status === 400) { res.status(HttpStatus.BAD_REQUEST).json({ @@ -99,4 +104,25 @@ export class StreamsController { } } } + + @Get('/existence') + @ApiOperation({summary: 'Get Session exited', description: '방송 세션에 대한 존재 여부를 반환 받습니다.'}) + async getExistence(@Query('sessionKey') sessionKey: string, @Res() res: Response) { + try { + if (this.memoryDBService.chzzkSwitch && sessionKey in this.memoryDBService.chzzkDb) { + res.status(HttpStatus.OK).json({existed: true}); + return; + } + const liveSessions = this.memoryDBService.findAll().filter((info) => info.state); + if (liveSessions.some((info) => info.sessionKey === sessionKey)) { + res.status(HttpStatus.OK).json({existed: true}); + } + else { + res.status(HttpStatus.OK).json({existed: false}); + } + } catch (err) { + console.log(err); + res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(); + } + } } \ No newline at end of file diff --git a/backend/rtmpServer/package.json b/backend/rtmpServer/package.json index 7ccb1366..edf0697a 100644 --- a/backend/rtmpServer/package.json +++ b/backend/rtmpServer/package.json @@ -3,7 +3,7 @@ "private": true, "packageManager": "yarn@4.5.3", "dependencies": { - "@hoeeeeeh/node-media-server": "3.0.2", + "@hoeeeeeh/node-media-server": "3.0.10", "@types/node": "^22.9.0", "dotenv": "^16.4.5", "path": "0.12.7" diff --git a/backend/rtmpServer/src/types/index.d.ts b/backend/rtmpServer/src/types/index.d.ts new file mode 100644 index 00000000..9ed9ec9d --- /dev/null +++ b/backend/rtmpServer/src/types/index.d.ts @@ -0,0 +1,104 @@ +interface Config { + logType?: number; + rtmp?: RtmpConfig; + http?: HttpConfig; + https?: SslConfig; + trans?: TransConfig; + relay?: RelayConfig; + fission?: FissionConfig; + auth?: AuthConfig; +} + +interface RtmpConfig { + port?: number; + ssl?: SslConfig; + chunk_size?: number; + gop_cache?: boolean; + ping?: number; + ping_timeout?: number; +} + +interface SslConfig { + key: string; + cert: string; + port?: number; +} + +interface HttpConfig { + mediaroot: string; + port?: number; + allow_origin?: string; +} + +interface AuthConfig { + play?: boolean; + publish?: boolean; + secret?: string; + api?: boolean; + api_user?: string; + api_pass?: string; +} + +interface TransConfig { + ffmpeg: string; + tasks: TransTaskConfig[]; +} + +interface RelayConfig { + tasks: RelayTaskConfig[]; + ffmpeg: string; +} + +interface FissionConfig { + ffmpeg: string; + tasks: FissionTaskConfig[]; +} + +interface TransTaskConfig { + app: string; + hls?: boolean; + hlsFlags?: string; + dash?: boolean; + dashFlags?: string; + vc?: string; + vcParam?: string[]; + ac?: string; + acParam?: string[]; + rtmp?: boolean; + rtmpApp?: string; + mp4?: boolean; + mp4Flags?: string; +} + +interface RelayTaskConfig { + app: string; + name?: string; + mode: string; + edge: string; + rtsp_transport?: string; + appendName?: boolean; +} + +interface FissionTaskConfig { + rule: string; + model: FissionTaskModel[]; +} + +interface FissionTaskModel { + ab: string; + vb: string; + vs: string; + vf: string; +} + +declare class NodeMediaServer { + constructor(config: Config); + run(): void; + on(eventName: string, listener: (id: string, StreamPath: string, args: object) => void): void; + stop(): void; + getSession(id: string): Map; +} + +declare module '@hoeeeeeh/node-media-server' { + export default NodeMediaServer; +} \ No newline at end of file diff --git a/backend/rtmpServer/tsconfig.json b/backend/rtmpServer/tsconfig.json index 5bc14d4b..a6594915 100644 --- a/backend/rtmpServer/tsconfig.json +++ b/backend/rtmpServer/tsconfig.json @@ -3,5 +3,6 @@ "compilerOptions": { "baseUrl": ".", "outDir": "./.dist" - } + }, + "typeRoots": ["./src/types"] } diff --git a/yarn.lock b/yarn.lock index ef00084f..8a065095 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2602,16 +2602,18 @@ __metadata: languageName: node linkType: hard -"@hoeeeeeh/node-media-server@npm:3.0.2": - version: 3.0.2 - resolution: "@hoeeeeeh/node-media-server@npm:3.0.2" +"@hoeeeeeh/node-media-server@npm:3.0.10": + version: 3.0.10 + resolution: "@hoeeeeeh/node-media-server@npm:3.0.10" dependencies: "@aws-sdk/client-s3": "npm:^3.688.0" "@types/node": "npm:^22.9.0" basic-auth-connect: "npm:^1.0.0" body-parser: "npm:1.20.3" chalk: "npm:^4.1.0" + chokidar: "npm:^4.0.1" dateformat: "npm:^4.6.3" + dotenv: "npm:^16.4.5" express: "npm:^4.19.2" http2-express-bridge: "npm:^1.0.7" lodash: "npm:^4.17.21" @@ -2620,7 +2622,7 @@ __metadata: ws: "npm:^8.13.0" bin: node-media-server: bin/app.js - checksum: 10c0/45b6c240e825e1e72a1100347c99b29bdb1462fb556a8af4337f6d134a5ab99eabb9e722056b314880e1f679d8609abfd866516f2f7bd7b7838880ce0ae0da22 + checksum: 10c0/ac5a520a8465d735dc6dcd7c9152da33f4a21d9db18447939df410c5797666f2b14c84675f53b75f7c22841063c205f52d24092b25a69c062e0537e41a37e4d4 languageName: node linkType: hard @@ -6138,6 +6140,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^4.0.1": + version: 4.0.1 + resolution: "chokidar@npm:4.0.1" + dependencies: + readdirp: "npm:^4.0.1" + checksum: 10c0/4bb7a3adc304059810bb6c420c43261a15bb44f610d77c35547addc84faa0374265c3adc67f25d06f363d9a4571962b02679268c40de07676d260de1986efea9 + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -11400,6 +11411,13 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:^4.0.1": + version: 4.0.2 + resolution: "readdirp@npm:4.0.2" + checksum: 10c0/a16ecd8ef3286dcd90648c3b103e3826db2b766cdb4a988752c43a83f683d01c7059158d623cbcd8bdfb39e65d302d285be2d208e7d9f34d022d912b929217dd + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -11784,7 +11802,7 @@ __metadata: resolution: "rtmpServer@workspace:backend/rtmpServer" dependencies: "@eslint/js": "npm:^9.13.0" - "@hoeeeeeh/node-media-server": "npm:3.0.2" + "@hoeeeeeh/node-media-server": "npm:3.0.10" "@types/node": "npm:^22.9.0" "@typescript-eslint/eslint-plugin": "npm:^8.14.0" "@typescript-eslint/parser": "npm:^8.14.0"