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
2 changes: 1 addition & 1 deletion prisma/dbml/schema.dbml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Table User {
idealPersonalities UserIdealPersonality [not null]
personalities UserPersonality [not null]
notifications Notification [not null]
notificaionsFrom Notification [not null]
notificationsFrom Notification [not null]
chatRooms ChatRoom [not null]
chatParticipations ChatParticipant [not null]
sentMessages ChatMessage [not null]
Expand Down
5 changes: 5 additions & 0 deletions src/common/errors/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ export const ERROR_DEFINITIONS = {
code: 'CHAT-003',
message: '파일 용량이 제한을 초과했습니다.',
},
CHAT_MESSAGE_BLOCKED: {
status: HttpStatus.FORBIDDEN,
code: 'CHAT-004',
message: '차단 상태에서는 채팅 기능을 이용할 수 없어요.',
},

// SYSTEM
SERVER_TEMPORARY_ERROR: {
Expand Down
78 changes: 78 additions & 0 deletions src/common/filters/ws-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
ArgumentsHost,
Catch,
HttpException,
type ExceptionFilter,
} from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import type { Socket } from 'socket.io';

import { AppException } from '../errors/app.exception';
import type { ExternalErrorCode } from '../errors/error-codes';
import type { ApiFailResponse } from '../dto/api-response.dto';

function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null;
}

@Catch()
export class WsExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ws = host.switchToWs();
const client = ws.getClient<Socket>();

const timestamp = new Date().toISOString();
const path = `ws${client?.nsp?.name ?? ''}`;

let code: ExternalErrorCode = 'SYS-001';
let message = '잠시 문제가 발생했어요. 잠시 후 다시 시도해 주세요.';

if (exception instanceof AppException) {
const body = exception.getResponse();

if (isRecord(body)) {
if (typeof body.code === 'string') {
code = body.code as ExternalErrorCode;
}
if (typeof body.message === 'string') {
message = body.message;
}
} else if (typeof body === 'string') {
message = body;
}
} else if (exception instanceof WsException) {
const err = exception.getError();
if (typeof err === 'string') {
message = err;
} else if (isRecord(err)) {
if (typeof err.code === 'string') {
code = err.code as ExternalErrorCode;
}
if (typeof err.message === 'string') {
message = err.message;
}
}
} else if (exception instanceof HttpException) {
const body = exception.getResponse();
if (typeof body === 'string') {
message = body;
} else if (exception.message) {
message = exception.message;
}
} else if (exception instanceof Error) {
if (process.env.NODE_ENV !== 'production' && exception.message) {
message = exception.message;
}
}

const payload: ApiFailResponse = {
resultType: 'FAIL',
success: null,
error: { code, message },
meta: { timestamp, path },
};

// socket.io 기반 NestWS는 기본적으로 'exception' 이벤트로 에러를 전달
client?.emit('exception', payload);
}
}
4 changes: 2 additions & 2 deletions src/modules/chat/controllers/message/message.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export class MessageController {
description: '요청값이 유효하지 않음 (메시지 없음 등)',
})
@ApiForbiddenResponse({
description: '권한 없음 (수신자가 아님 / 참여자가 아님)',
description: '권한 없음 (수신자가 아님 / 참여자가 아님 / 차단 상태)',
})
@ApiUnauthorizedResponse({ description: '인증 실패 (액세스 토큰 필요)' })
async markAsRead(
Expand Down Expand Up @@ -166,7 +166,7 @@ export class MessageController {
description: '요청값이 유효하지 않음 (메시지 없음 등)',
})
@ApiForbiddenResponse({
description: '권한 없음 (송신자/수신자가 아님 / 참여자가 아님)',
description: '권한 없음 (송신자/수신자가 아님 / 참여자가 아님 / 차단 상태)',
})
@ApiUnauthorizedResponse({ description: '인증 실패 (액세스 토큰 필요)' })
async deleteMessage(
Expand Down
76 changes: 53 additions & 23 deletions src/modules/chat/gateways/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,18 @@
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
WsException,
} from '@nestjs/websockets';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, UseFilters } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ActiveStatus,
ChatMediaType,
NotificationType,
type Notification,
} from '@prisma/client';
import { ActiveStatus, ChatMediaType, NotificationType } from '@prisma/client';
import type { Server, Socket, DefaultEventsMap } from 'socket.io';
import { PrismaService } from '../../../infra/prisma/prisma.service';
import { JwtTokenService } from '../../auth/services/jwt-token.service';
import { ChatMediaService } from '../services/chat-media/chat-media.service';
import { NotificationService } from '../../notification/services/notification.service';
import { buildMessagePreview } from '../utils/message-preview.util';
import { AppException } from '../../../common/errors/app.exception';
import { WsExceptionFilter } from '../../../common/filters/ws-exception.filter';

type SocketData = { userId?: number };

Expand Down Expand Up @@ -87,6 +83,7 @@
namespace: '/chats',
cors: { origin: true, credentials: true },
})
@UseFilters(new WsExceptionFilter())
@Injectable()
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(ChatGateway.name);
Expand All @@ -102,9 +99,6 @@
@WebSocketServer()
server!: Server;

/**
* 여러 room 대상으로 "한 번만" emit (room + user 룸 중복 join 시에도 중복 수신 방지)
*/
private emitToRooms(rooms: string[], event: string, payload: unknown): void {
if (!this.server) return;
const uniqueRooms = [...new Set(rooms)];
Expand Down Expand Up @@ -195,7 +189,7 @@

const userId = Number(userRecord.id);
client.data.userId = userId;
client.join(`user:${userId}`);

Check warning on line 192 in src/modules/chat/gateways/chat.gateway.ts

View workflow job for this annotation

GitHub Actions / CI - Test & Build

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

this.logger.log(`connected: socket=${client.id} userId=${userId}`);
} catch (e) {
Expand Down Expand Up @@ -265,19 +259,22 @@
};
}

// 입장은 허용
@SubscribeMessage('room.join')
async onJoinRoom(
@ConnectedSocket() client: AuthedSocket,
@MessageBody() body: JoinRoomBody,
) {
const chatRoomId = toPositiveInt(body?.chatRoomId);
if (!chatRoomId) {
throw new WsException('Invalid chatRoomId');
throw new AppException('VALIDATION_INVALID_FORMAT', {
message: 'chatRoomId가 올바르지 않습니다.',
});
}

const senderUserId = client.data.userId;
if (!senderUserId) {
throw new WsException('Unauthorized socket');
throw new AppException('AUTH_LOGIN_REQUIRED');
}

const me = BigInt(senderUserId);
Expand All @@ -292,28 +289,31 @@
});

if (!isParticipant) {
throw new WsException('Not a participant of this room');
throw new AppException('CHAT_ROOM_ACCESS_FAILED');
}

const room = `room:${chatRoomId}`;
client.join(room);

Check warning on line 296 in src/modules/chat/gateways/chat.gateway.ts

View workflow job for this annotation

GitHub Actions / CI - Test & Build

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

return { ok: true, joined: room };
}

// 전송은 차단 체크
@SubscribeMessage('message.send')
async onSendMessage(
@ConnectedSocket() client: AuthedSocket,
@MessageBody() body: SendMessageBody,
) {
const chatRoomId = toPositiveInt(body?.chatRoomId);
if (!chatRoomId) {
throw new WsException('Invalid chatRoomId');
throw new AppException('VALIDATION_INVALID_FORMAT', {
message: 'chatRoomId가 올바르지 않습니다.',
});
}

const senderUserId = client.data.userId;
if (!senderUserId) {
throw new WsException('Unauthorized socket');
throw new AppException('AUTH_LOGIN_REQUIRED');
}

this.logger.debug(
Expand All @@ -322,25 +322,33 @@

const type = body?.type;
if (!isChatMediaType(type)) {
throw new WsException('Invalid message type');
throw new AppException('VALIDATION_INVALID_FORMAT', {
message: '메시지 타입이 올바르지 않습니다.',
});
}

if (type === 'TEXT') {
const text = typeof body?.text === 'string' ? body.text.trim() : '';
if (!text) {
throw new WsException('Message text is required');
throw new AppException('VALIDATION_REQUIRED_FIELD_MISSING', {
message: '텍스트 메시지는 내용이 필요합니다.',
});
}
} else {
const mediaUrl =
typeof body?.mediaUrl === 'string' ? body.mediaUrl.trim() : '';
if (!mediaUrl) {
throw new WsException('Media URL is required');
throw new AppException('VALIDATION_REQUIRED_FIELD_MISSING', {
message: '미디어 메시지는 mediaUrl이 필요합니다.',
});
}

if (type === 'AUDIO' || type === 'VIDEO') {
const durationSec = toPositiveInt(body?.durationSec);
if (!durationSec) {
throw new WsException('durationSec is required for AUDIO/VIDEO');
throw new AppException('VALIDATION_REQUIRED_FIELD_MISSING', {
message: '오디오/비디오 메시지는 durationSec이 필요합니다.',
});
}
}

Expand All @@ -361,19 +369,25 @@
});

if (!isParticipant) {
throw new WsException('Not a participant of this room');
throw new AppException('CHAT_ROOM_ACCESS_FAILED');
}

const peer = await this.prisma.chatParticipant.findFirst({
where: {
roomId,
userId: { not: me },
endedAt: null,
},
select: { userId: true, endedAt: true },
select: { userId: true },
});

if (!peer) {
throw new WsException('Peer not found');
throw new AppException('CHAT_ROOM_ACCESS_FAILED');
}

const isBlocked = await this.isBlockedBetweenUsers(me, peer.userId);
if (isBlocked) {
throw new AppException('CHAT_MESSAGE_BLOCKED');
}

const message = await this.prisma.chatMessage.create({
Expand Down Expand Up @@ -445,4 +459,20 @@

return { ok: true, messageId: Number(message.id) };
}

private async isBlockedBetweenUsers(a: bigint, b: bigint): Promise<boolean> {
const found = await this.prisma.block.findFirst({
where: {
deletedAt: null,
status: 'BLOCKED',
OR: [
{ blockedById: a, blockedId: b },
{ blockedById: b, blockedId: a },
],
},
select: { id: true },
});

return found !== null;
}
}
16 changes: 16 additions & 0 deletions src/modules/chat/repositories/participant.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import { PrismaService } from '../../../infra/prisma/prisma.service';
export class ParticipantRepository {
constructor(private readonly prisma: PrismaService) {}

async isBlockedBetweenUsers(a: bigint, b: bigint): Promise<boolean> {
const found = await this.prisma.block.findFirst({
where: {
deletedAt: null,
status: 'BLOCKED',
OR: [
{ blockedById: a, blockedId: b },
{ blockedById: b, blockedId: a },
],
},
select: { id: true },
});

return found !== null;
}

async getMyActiveParticipation(
me: bigint,
roomId: bigint,
Expand Down
9 changes: 9 additions & 0 deletions src/modules/chat/services/chat-media/chat-media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ export class ChatMediaService {
const ok = await this.participantRepo.isParticipant(me, roomId);
if (!ok) throw new AppException('CHAT_ROOM_ACCESS_FAILED');

const peerUserId = await this.participantRepo.findPeerUserId(roomId, me);
if (!peerUserId) throw new AppException('CHAT_ROOM_ACCESS_FAILED');

const isBlocked = await this.participantRepo.isBlockedBetweenUsers(
me,
peerUserId,
);
if (isBlocked) throw new AppException('CHAT_MESSAGE_BLOCKED');

if (!isAllowedContentType(dto.type, dto.contentType)) {
throw new AppException('VALIDATION_INVALID_FORMAT', {
message: 'contentType이 업로드 타입과 일치하지 않습니다.',
Expand Down
Loading