Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ec13f68
refactor: npm ci & prisma
annalee8595 Feb 10, 2026
fa07f07
Merge branch 'dev' of https://github.com/UMC-Eum/Backend into dev
annalee8595 Feb 10, 2026
7526566
Merge branch 'dev' of https://github.com/UMC-Eum/Backend into dev
annalee8595 Feb 11, 2026
314155a
fix: swagger 예시 키워드 수정
annalee8595 Feb 12, 2026
9a243d4
Merge branch 'dev' into fix/swagger-keyword
annalee8595 Feb 12, 2026
fb75634
Merge branch 'feat/send-chat-media' of https://github.com/UMC-Eum/Bac…
jihuuuu Feb 18, 2026
e16bed8
feat: 차단 시 채팅 기능 제한(생성/전송/읽음/삭제) 및 WS 에러 응답 통일
jihuuuu Feb 18, 2026
f2dacee
Merge branch 'dev' of https://github.com/UMC-Eum/Backend into feat/bl…
jihuuuu Feb 18, 2026
d3220f3
chore: 마이그레이션 반영
jihuuuu Feb 18, 2026
532a7f1
refactor: ws 공용 모듈 분리로 gateway 역할 축소
jihuuuu Feb 18, 2026
4a1dc47
fix: WebSocket 예외 처리 로깅 보강
jihuuuu Feb 19, 2026
d38cc2a
fix: lint 수정
jihuuuu Feb 19, 2026
9761b4c
Merge pull request #162 from UMC-Eum/feat/block-sending-message
jihuuuu Feb 19, 2026
11c0216
fix: npm format
annalee8595 Feb 19, 2026
44ab46f
Merge branch 'fix/swagger-keyword' of https://github.com/UMC-Eum/Back…
annalee8595 Feb 19, 2026
8eef74c
Merge pull request #155 from UMC-Eum/fix/swagger-keyword
annalee8595 Feb 19, 2026
b6b726a
refactor : 서비스 레이어 파라미터 추가
Lidohyeon Feb 19, 2026
f7791d7
refactor : 마음 생성시 파라미터 추가
Lidohyeon Feb 19, 2026
0ef9cbd
refactor : Get 마음 API vibevector 삭제
Lidohyeon Feb 19, 2026
e475408
refactor : block interceptor 로직 수정
Lidohyeon Feb 19, 2026
6532331
Merge pull request #166 from UMC-Eum/165-refactor-알림-파라미터-수정-및-block-…
Lidohyeon Feb 19, 2026
8a92b3a
Merge pull request #164 from UMC-Eum/feat/active-now
jihuuuu Feb 19, 2026
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
144 changes: 144 additions & 0 deletions src/common/filters/ws-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
ArgumentsHost,
Catch,
HttpException,
Logger,
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;
}

function toErrorInfo(e: unknown): {
name: string;
message: string;
stack?: string;
} {
if (e instanceof Error) {
return { name: e.name, message: e.message, stack: e.stack };
}
return { name: 'UnknownError', message: String(e) };
}

function safeJson(v: unknown): string {
try {
return JSON.stringify(v);
} catch {
return '[unserializable]';
}
}

@Catch()
export class WsExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(WsExceptionFilter.name);

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 ?? ''}`;

const socketId = client?.id ?? 'N/A';
const ip = (client?.handshake as { address?: string } | undefined)?.address;

// Socket.data는 프로젝트에서 타입을 강하게 안 박았을 수 있어서 unknown 처리
const dataUnknown = (client as unknown as { data?: unknown }).data;
const userId =
isRecord(dataUnknown) && typeof dataUnknown.userId === 'number'
? dataUnknown.userId
: undefined;

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;
}

// 예상 가능한 도메인 예외: 운영 관점에서 원인 파악 가능하도록 warn 로깅
this.logger.warn(
`ws app exception: code=${code} socket=${socketId} userId=${String(userId)} ip=${String(ip)} path=${path}`,
);
} 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;
}
}

// 예상 가능한 WS 예외: warn 로깅
this.logger.warn(
`ws exception: code=${code} socket=${socketId} userId=${String(userId)} ip=${String(ip)} path=${path} detail=${safeJson(err)}`,
);
} else if (exception instanceof HttpException) {
const body = exception.getResponse();

if (typeof body === 'string') {
message = body;
} else if (exception.message) {
message = exception.message;
}

// HTTP 예외가 WS로 흘러온 케이스: warn 로깅
this.logger.warn(
`ws http exception: status=${exception.getStatus()} socket=${socketId} userId=${String(userId)} ip=${String(ip)} path=${path} detail=${safeJson(body)}`,
);
} else if (exception instanceof Error) {
// 예상 못한 서버 에러: 반드시 error + stack 로깅 (피드백 해결 핵심)
const info = toErrorInfo(exception);
this.logger.error(
`ws unexpected error: socket=${socketId} userId=${String(userId)} ip=${String(ip)} path=${path} err=${info.name}:${info.message}`,
info.stack,
);

if (process.env.NODE_ENV !== 'production' && exception.message) {
message = exception.message;
}
} else {
// unknown도 로깅
const info = toErrorInfo(exception);
this.logger.error(
`ws unknown throw: socket=${socketId} userId=${String(userId)} ip=${String(ip)} path=${path} err=${info.name}:${info.message}`,
);

if (process.env.NODE_ENV !== 'production') {
message = info.message;
}
}

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

// socket.io 기반 NestWS는 기본적으로 'exception' 이벤트로 에러를 전달
client?.emit('exception', payload);
}
}
34 changes: 15 additions & 19 deletions src/common/interceptors/block-filter.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,8 @@ export class BlockFilterInterceptor implements NestInterceptor {
return false;
}

// 사용자 ID 필드들 확인
// 사용자 ID 필드들 확인 (id는 제외 - heartId 등과 혼동 방지)
const userIdFields = [
'id',
'userId',
'authorId',
'senderId',
Expand All @@ -202,24 +201,21 @@ export class BlockFilterInterceptor implements NestInterceptor {
}

// 중첩된 사용자 객체 확인
if (item.user && item.user.id) {
const userId = item.user.id.toString();
if (blockedUserIds.includes(userId)) {
return true;
}
}

if (item.author && item.author.id) {
const userId = item.author.id.toString();
if (blockedUserIds.includes(userId)) {
return true;
}
}
const userObjectFields = [
'user',
'author',
'sender',
'fromUser',
'targetUser',
'recipient',
];

if (item.sender && item.sender.id) {
const userId = item.sender.id.toString();
if (blockedUserIds.includes(userId)) {
return true;
for (const field of userObjectFields) {
if (item[field] && item[field].id) {
const userId = item[field].id.toString();
if (blockedUserIds.includes(userId)) {
return true;
}
}
}

Expand Down
156 changes: 156 additions & 0 deletions src/infra/websocket/auth/ws-auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ActiveStatus } from '@prisma/client';
import type { DefaultEventsMap, Socket } from 'socket.io';

import { PrismaService } from '../../prisma/prisma.service';
import { JwtTokenService } from '../../../modules/auth/services/jwt-token.service';
import { toUserRoom } from '../utils/ws-rooms.util';

type SocketData = { userId?: number };

type AuthedSocket = Socket<
DefaultEventsMap,
DefaultEventsMap,
DefaultEventsMap,
SocketData
>;

function extractBearerToken(client: AuthedSocket): string | null {
const auth = client.handshake.auth as Record<string, unknown> | undefined;
const query = client.handshake.query as Record<string, unknown> | undefined;

const raw =
auth?.token ?? auth?.accessToken ?? query?.token ?? query?.accessToken;

if (typeof raw !== 'string') return null;

const trimmed = raw.trim();
if (!trimmed) return null;

return trimmed.startsWith('Bearer ') ? trimmed : `Bearer ${trimmed}`;
}

function toErrorInfo(e: unknown): {
name: string;
message: string;
stack?: string;
} {
if (e instanceof Error) {
return { name: e.name, message: e.message, stack: e.stack };
}
return { name: 'UnknownError', message: String(e) };
}

function toPositiveBigInt(v: unknown): bigint | null {
if (typeof v === 'number') {
if (!Number.isFinite(v) || v <= 0) return null;
return BigInt(Math.floor(v));
Comment on lines +46 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject fractional JWT subject values

toPositiveBigInt floors numeric sub claims (1.9 becomes 1) instead of rejecting them, and WsAuthService.attachUser uses that parsed value as the authenticated user ID. A signed token with a non-integer numeric sub would therefore authenticate as a different account, which is an identity-mapping bug in auth handling; this parser should require an integer value before converting.

Useful? React with 👍 / 👎.

}

if (typeof v === 'string') {
const s = v.trim();
if (!/^\d+$/.test(s)) return null;

try {
const n = BigInt(s);
if (n <= 0n) return null;
return n;
} catch {
return null;
}
}

return null;
}

@Injectable()
export class WsAuthService {
private readonly logger = new Logger(WsAuthService.name);

constructor(
private readonly prisma: PrismaService,
private readonly jwtTokenService: JwtTokenService,
private readonly configService: ConfigService,
) {}

async attachUser(client: AuthedSocket): Promise<number | null> {
const socketId = client.id;
const ip = client.handshake.address;

const bearer = extractBearerToken(client);
if (!bearer) {
this.logger.debug(
`attachUser auth failed: missing token socket=${socketId} ip=${ip}`,
);
return null;
}

const token = bearer.replace(/^Bearer\s+/i, '').trim();
if (!token) {
this.logger.debug(
`attachUser auth failed: empty token socket=${socketId} ip=${ip}`,
);
return null;
}

const secret = this.configService.get<string>(
'JWT_ACCESS_SECRET',
'dev-access-secret',
);

let subId: bigint | null = null;

try {
const payload = this.jwtTokenService.verify(token, secret) as unknown;
const sub = (payload as { sub?: unknown }).sub;

subId = toPositiveBigInt(sub);
if (!subId) {
this.logger.warn(
`attachUser auth failed: invalid sub socket=${socketId} ip=${ip}`,
);
return null;
}
} catch (e) {
const info = toErrorInfo(e);
// 토큰 원문은 절대 로그에 남기지 않음
this.logger.warn(
`attachUser auth failed: jwt verify error socket=${socketId} ip=${ip} err=${info.name}:${info.message}`,
);
return null;
}

try {
const userRecord = await this.prisma.user.findFirst({
where: {
id: subId,
deletedAt: null,
status: ActiveStatus.ACTIVE,
},
select: { id: true },
});

if (!userRecord) {
this.logger.debug(
`attachUser auth failed: user not found socket=${socketId} ip=${ip} sub=${subId.toString()}`,
);
return null;
}

const userId = Number(userRecord.id);

client.data.userId = userId;
client.join(toUserRoom(userId));

Check warning on line 144 in src/infra/websocket/auth/ws-auth.service.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

Check warning on line 144 in src/infra/websocket/auth/ws-auth.service.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 userId;
} catch (e) {
const info = toErrorInfo(e);
this.logger.error(
`attachUser error: prisma query failed socket=${socketId} ip=${ip} sub=${subId.toString()} err=${info.name}:${info.message}`,
info.stack,
);
return null;
}
}
}
Loading