summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-18 07:52:02 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-18 07:52:02 +0000
commit48a2255bfc45ffcfb0b39ffefdd57cbacf8b36df (patch)
tree0c88b7c126138233875e8d372a4e999e49c38a62 /lib
parent2ef02e27dbe639876fa3b90c30307dda183545ec (diff)
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'lib')
-rw-r--r--lib/file-download-log/service.ts353
-rw-r--r--lib/file-download-log/validation.ts37
-rw-r--r--lib/file-download.ts471
-rw-r--r--lib/file-stroage.ts588
-rw-r--r--lib/network/get-client-ip.ts116
-rw-r--r--lib/notification/NotificationContext.tsx219
-rw-r--r--lib/notification/service.ts342
-rw-r--r--lib/rate-limit.ts63
-rw-r--r--lib/realtime/NotificationManager.ts223
-rw-r--r--lib/realtime/RealtimeNotificationService.ts362
-rw-r--r--lib/sedp/get-form-tags.ts18
-rw-r--r--lib/tags/form-mapping-service.ts28
12 files changed, 2702 insertions, 118 deletions
diff --git a/lib/file-download-log/service.ts b/lib/file-download-log/service.ts
new file mode 100644
index 00000000..b2350782
--- /dev/null
+++ b/lib/file-download-log/service.ts
@@ -0,0 +1,353 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { headers } from 'next/headers';
+import db from '@/db/db';
+import { fileDownloadLogs, userDownloadStats, rfqAttachments } from '@/db/schema';
+import { eq, and, gte, sql } from 'drizzle-orm';
+import {
+ createFileDownloadLogSchema,
+ type CreateFileDownloadLogInput,
+ type FileDownloadLog
+} from './validation';
+import { z } from 'zod';
+
+// 요청 정보 타입
+interface RequestInfo {
+ ip?: string;
+ userAgent?: string;
+ referer?: string;
+ sessionId?: string;
+}
+
+// 헤더에서 요청 정보 추출
+async function getRequestInfo(): Promise<RequestInfo> {
+ const headersList = await headers();
+
+ return {
+ ip: headersList.get('x-forwarded-for')?.split(',')[0] ||
+ headersList.get('x-real-ip') ||
+ headersList.get('cf-connecting-ip') || // Cloudflare
+ undefined,
+ userAgent: headersList.get('user-agent') || undefined,
+ referer: headersList.get('referer') || undefined,
+ sessionId: headersList.get('x-session-id') || undefined, // 커스텀 세션 ID
+ };
+ }
+
+// 파일 다운로드 로그 생성 (메인 서버 액션)
+export async function createFileDownloadLog(
+ input: CreateFileDownloadLogInput
+ ): Promise<{ success: boolean; error?: string; logId?: number }> {
+ try {
+ const validatedInput = createFileDownloadLogSchema.parse(input);
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ const requestInfo = await getRequestInfo();
+
+ const fileInfo = validatedInput.fileInfo;
+
+
+ // 다운로드 로그 생성
+ const [newLog] = await db
+ .insert(fileDownloadLogs)
+ .values({
+ fileId: validatedInput.fileId,
+ userId: session.user.id,
+ userEmail: session.user.email || undefined,
+ userName: session.user.name || undefined,
+ userIP: requestInfo.ip,
+ userAgent: requestInfo.userAgent,
+ fileName: fileInfo?.fileName,
+ filePath: fileInfo?.filePath,
+ fileSize: fileInfo?.fileSize,
+ success: validatedInput.success,
+ errorMessage: validatedInput.errorMessage,
+ sessionId: requestInfo.sessionId,
+ requestId: validatedInput.requestId,
+ referer: requestInfo.referer,
+ downloadDurationMs: validatedInput.downloadDurationMs,
+ downloadedAt: new Date(),
+ })
+ .returning({ id: fileDownloadLogs.id });
+
+ // 성공한 다운로드인 경우 통계 업데이트
+ if (validatedInput.success && fileInfo?.fileSize) {
+ await updateUserDownloadStats(
+ session.user.id,
+ fileInfo.fileSize,
+ validatedInput.fileId
+ );
+ }
+
+ return { success: true, logId: newLog.id };
+
+ } catch (error) {
+ console.error('파일 다운로드 로그 생성 실패:', error);
+
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: `입력값 검증 실패: ${error.errors.map(e => e.message).join(', ')}`
+ };
+ }
+
+ return { success: false, error: '로그 생성 중 오류가 발생했습니다.' };
+ }
+ }
+// 사용자 다운로드 통계 업데이트
+async function updateUserDownloadStats(
+ userId: string,
+ fileSize: number,
+ fileId: number
+): Promise<void> {
+ try {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // 오늘 날짜의 통계가 있는지 확인
+ const [existingStats] = await db
+ .select()
+ .from(userDownloadStats)
+ .where(
+ and(
+ eq(userDownloadStats.userId, userId),
+ gte(userDownloadStats.date, today)
+ )
+ )
+ .limit(1);
+
+ if (existingStats) {
+ // 기존 통계 업데이트
+ await db
+ .update(userDownloadStats)
+ .set({
+ totalDownloads: sql`${userDownloadStats.totalDownloads} + 1`,
+ totalBytes: sql`${userDownloadStats.totalBytes} + ${fileSize}`,
+ lastDownloadAt: new Date(),
+ // uniqueFiles는 별도 로직으로 계산 (복잡하므로 생략)
+ })
+ .where(eq(userDownloadStats.id, existingStats.id));
+ } else {
+ // 새 통계 생성
+ await db.insert(userDownloadStats).values({
+ userId,
+ date: today,
+ totalDownloads: 1,
+ totalBytes: fileSize,
+ uniqueFiles: 1,
+ lastDownloadAt: new Date(),
+ });
+ }
+ } catch (error) {
+ console.error('다운로드 통계 업데이트 실패:', error);
+ // 통계 실패가 메인 기능에 영향을 주지 않도록 에러를 던지지 않음
+ }
+}
+
+// 사용자 다운로드 이력 조회
+export async function getUserDownloadHistory(
+ page: number = 1,
+ limit: number = 20
+): Promise<{
+ logs: FileDownloadLog[],
+ total: number,
+ hasMore: boolean
+}> {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error('Unauthorized');
+ }
+
+ const offset = (page - 1) * limit;
+
+ // 총 개수 조회
+ const [{ count }] = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(fileDownloadLogs)
+ .where(eq(fileDownloadLogs.userId, session.user.id));
+
+ // 로그 조회
+ const logs = await db
+ .select()
+ .from(fileDownloadLogs)
+ .where(eq(fileDownloadLogs.userId, session.user.id))
+ .orderBy(sql`${fileDownloadLogs.downloadedAt} DESC`)
+ .limit(limit)
+ .offset(offset);
+
+ return {
+ logs,
+ total: count,
+ hasMore: offset + logs.length < count,
+ };
+
+ } catch (error) {
+ console.error('다운로드 이력 조회 실패:', error);
+ throw new Error('다운로드 이력을 가져올 수 없습니다.');
+ }
+}
+
+// 사용자 다운로드 통계 조회
+export async function getUserDownloadStats(days: number = 30): Promise<{
+ totalDownloads: number;
+ totalBytes: number;
+ uniqueFiles: number;
+ dailyStats: Array<{
+ date: string;
+ downloads: number;
+ bytes: number;
+ }>;
+}> {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error('Unauthorized');
+ }
+
+ const startDate = new Date();
+ startDate.setDate(startDate.getDate() - days);
+
+ // 기간별 통계 조회
+ const dailyStats = await db
+ .select({
+ date: userDownloadStats.date,
+ downloads: userDownloadStats.totalDownloads,
+ bytes: userDownloadStats.totalBytes,
+ })
+ .from(userDownloadStats)
+ .where(
+ and(
+ eq(userDownloadStats.userId, session.user.id),
+ gte(userDownloadStats.date, startDate)
+ )
+ )
+ .orderBy(userDownloadStats.date);
+
+ // 총합 계산
+ const totals = dailyStats.reduce(
+ (acc, stat) => ({
+ totalDownloads: acc.totalDownloads + stat.downloads,
+ totalBytes: acc.totalBytes + stat.bytes,
+ }),
+ { totalDownloads: 0, totalBytes: 0 }
+ );
+
+ // 고유 파일 수 계산 (기간 내)
+ const [{ uniqueFiles }] = await db
+ .select({
+ uniqueFiles: sql<number>`count(distinct ${fileDownloadLogs.fileId})`
+ })
+ .from(fileDownloadLogs)
+ .where(
+ and(
+ eq(fileDownloadLogs.userId, session.user.id),
+ eq(fileDownloadLogs.success, true),
+ gte(fileDownloadLogs.downloadedAt, startDate)
+ )
+ );
+
+ return {
+ ...totals,
+ uniqueFiles: uniqueFiles || 0,
+ dailyStats: dailyStats.map(stat => ({
+ date: stat.date.toISOString().split('T')[0],
+ downloads: stat.downloads,
+ bytes: stat.bytes,
+ })),
+ };
+
+ } catch (error) {
+ console.error('다운로드 통계 조회 실패:', error);
+ throw new Error('다운로드 통계를 가져올 수 없습니다.');
+ }
+}
+
+// 관리자용: 전체 다운로드 로그 조회 (권한 확인 필요)
+export async function getAllDownloadLogs(
+ page: number = 1,
+ limit: number = 50,
+ filters?: {
+ userId?: string;
+ success?: boolean;
+ startDate?: Date;
+ endDate?: Date;
+ }
+): Promise<{
+ logs: FileDownloadLog[],
+ total: number,
+ hasMore: boolean
+}> {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user || !session.user.roles.includes('admin') ) {
+ throw new Error('관리자 권한이 필요합니다.');
+ }
+
+ const offset = (page - 1) * limit;
+
+ // 필터 조건 구성
+ const conditions = [];
+ if (filters?.userId) {
+ conditions.push(eq(fileDownloadLogs.userId, filters.userId));
+ }
+ if (filters?.success !== undefined) {
+ conditions.push(eq(fileDownloadLogs.success, filters.success));
+ }
+ if (filters?.startDate) {
+ conditions.push(gte(fileDownloadLogs.downloadedAt, filters.startDate));
+ }
+ if (filters?.endDate) {
+ conditions.push(sql`${fileDownloadLogs.downloadedAt} <= ${filters.endDate}`);
+ }
+
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
+
+ // 총 개수 조회
+ const [{ count }] = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(fileDownloadLogs)
+ .where(whereClause);
+
+ // 로그 조회
+ const logs = await db
+ .select()
+ .from(fileDownloadLogs)
+ .where(whereClause)
+ .orderBy(sql`${fileDownloadLogs.downloadedAt} DESC`)
+ .limit(limit)
+ .offset(offset);
+
+ return {
+ logs,
+ total: count,
+ hasMore: offset + logs.length < count,
+ };
+
+ } catch (error) {
+ console.error('전체 다운로드 로그 조회 실패:', error);
+ throw new Error('다운로드 로그를 가져올 수 없습니다.');
+ }
+}
+
+// 편의 함수: 간단한 로그 생성
+export async function logDownloadAttempt(
+ fileId: number,
+ success: boolean,
+ errorMessage?: string,
+ requestId?: string
+): Promise<void> {
+ await createFileDownloadLog({
+ fileId,
+ success,
+ errorMessage,
+ requestId,
+ });
+} \ No newline at end of file
diff --git a/lib/file-download-log/validation.ts b/lib/file-download-log/validation.ts
new file mode 100644
index 00000000..bf888e9c
--- /dev/null
+++ b/lib/file-download-log/validation.ts
@@ -0,0 +1,37 @@
+import { fileDownloadLogs } from "@/db/schema";
+import { createInsertSchema, createSelectSchema } from "drizzle-zod";
+import { z } from 'zod';
+
+// Zod 스키마
+export const fileDownloadLogInsertSchema = createInsertSchema(fileDownloadLogs);
+export const fileDownloadLogSelectSchema = createSelectSchema(fileDownloadLogs);
+
+export type FileDownloadLog = typeof fileDownloadLogs.$inferSelect;
+export type NewFileDownloadLog = typeof fileDownloadLogs.$inferInsert;
+
+// validation.ts에 추가할 타입
+interface FileInfo {
+ fileName?: string;
+ filePath?: string;
+ fileSize?: number;
+ }
+
+ // 수정된 입력 스키마
+export const createFileDownloadLogSchema = z.object({
+ fileId: z.number(),
+ success: z.boolean(),
+ errorMessage: z.string().optional(),
+ requestId: z.string().optional(),
+ downloadDurationMs: z.number().optional(),
+ // 파일 정보를 직접 받을 수 있도록 추가
+ fileInfo: z.object({
+ fileName: z.string().optional(),
+ filePath: z.string().optional(),
+ fileSize: z.number().optional(),
+ }).optional(),
+ // 또는 다른 테이블에서 조회할 수 있도록
+ fileSource: z.enum(['rfqAttachments', 'documents', 'uploads']).optional(),
+ });
+
+
+export type CreateFileDownloadLogInput = z.infer<typeof createFileDownloadLogSchema>;
diff --git a/lib/file-download.ts b/lib/file-download.ts
index 1e8536b5..5e0350a0 100644
--- a/lib/file-download.ts
+++ b/lib/file-download.ts
@@ -1,5 +1,5 @@
// lib/file-download.ts
-// 공용 파일 다운로드 유틸리티
+// 공용 파일 다운로드 유틸리티 (보안 및 로깅 강화)
import { toast } from "sonner";
@@ -14,6 +14,240 @@ export interface FileInfo {
}
/**
+ * 파일 다운로드 옵션
+ */
+export interface FileDownloadOptions {
+ /** 다운로드 액션 타입 */
+ action?: 'download' | 'preview';
+ /** 에러 시 토스트 표시 여부 */
+ showToast?: boolean;
+ /** 성공 시 토스트 표시 여부 */
+ showSuccessToast?: boolean;
+ /** 커스텀 에러 핸들러 */
+ onError?: (error: string) => void;
+ /** 커스텀 성공 핸들러 */
+ onSuccess?: (fileName: string, fileSize?: number) => void;
+ /** 진행률 콜백 (큰 파일용) */
+ onProgress?: (progress: number) => void;
+ /** 로깅 비활성화 */
+ disableLogging?: boolean;
+}
+
+/**
+ * 파일 다운로드 결과
+ */
+export interface FileDownloadResult {
+ success: boolean;
+ error?: string;
+ fileSize?: number;
+ fileInfo?: FileInfo;
+ downloadDuration?: number;
+}
+
+/**
+ * 보안 설정
+ */
+const SECURITY_CONFIG = {
+ // 허용된 파일 확장자
+ ALLOWED_EXTENSIONS: new Set([
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
+ 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg',
+ 'dwg', 'dxf', 'zip', 'rar', '7z', 'webp'
+ ]),
+
+ // 최대 파일 크기 (100MB)
+ MAX_FILE_SIZE: 100 * 1024 * 1024,
+
+ // 허용된 도메인 (선택적)
+ ALLOWED_DOMAINS: [
+ window.location.hostname,
+ 'localhost',
+ '127.0.0.1'
+ ],
+
+ // Rate limiting (클라이언트 사이드)
+ MAX_DOWNLOADS_PER_MINUTE: 10,
+
+ // 타임아웃 설정
+ FETCH_TIMEOUT: 30000, // 30초
+};
+
+/**
+ * Rate limiting 추적
+ */
+class RateLimiter {
+ private downloadAttempts: number[] = [];
+
+ canDownload(): boolean {
+ const now = Date.now();
+ const oneMinuteAgo = now - 60000;
+
+ // 1분 이전 기록 제거
+ this.downloadAttempts = this.downloadAttempts.filter(time => time > oneMinuteAgo);
+
+ if (this.downloadAttempts.length >= SECURITY_CONFIG.MAX_DOWNLOADS_PER_MINUTE) {
+ return false;
+ }
+
+ this.downloadAttempts.push(now);
+ return true;
+ }
+
+ getRemainingDownloads(): number {
+ const now = Date.now();
+ const oneMinuteAgo = now - 60000;
+ this.downloadAttempts = this.downloadAttempts.filter(time => time > oneMinuteAgo);
+
+ return Math.max(0, SECURITY_CONFIG.MAX_DOWNLOADS_PER_MINUTE - this.downloadAttempts.length);
+ }
+}
+
+const rateLimiter = new RateLimiter();
+
+/**
+ * 클라이언트 사이드 로깅 서비스
+ */
+class ClientLogger {
+ private static async logToServer(event: string, data: any) {
+ try {
+ // 로깅이 비활성화된 경우 서버 전송 안함
+ if (data.disableLogging) {
+ console.log(`[CLIENT LOG] ${event}:`, data);
+ return;
+ }
+
+ await fetch('/api/client-logs', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ event,
+ data: {
+ ...data,
+ timestamp: new Date().toISOString(),
+ userAgent: navigator.userAgent,
+ url: window.location.href,
+ sessionId: this.getSessionId(),
+ },
+ }),
+ }).catch(error => {
+ // 로깅 실패해도 메인 기능에 영향 없도록
+ console.warn('로깅 실패:', error);
+ });
+ } catch (error) {
+ console.warn('로깅 실패:', error);
+ }
+ }
+
+ private static getSessionId(): string {
+ let sessionId = sessionStorage.getItem('client-session-id');
+ if (!sessionId) {
+ sessionId = crypto.randomUUID();
+ sessionStorage.setItem('client-session-id', sessionId);
+ }
+ return sessionId;
+ }
+
+ static logDownloadAttempt(filePath: string, fileName: string, action: string, options?: any) {
+ this.logToServer('download_attempt', {
+ filePath,
+ fileName,
+ action,
+ fileExtension: fileName.split('.').pop()?.toLowerCase(),
+ ...options,
+ });
+ }
+
+ static logDownloadSuccess(filePath: string, fileName: string, fileSize?: number, duration?: number) {
+ this.logToServer('download_success', {
+ filePath,
+ fileName,
+ fileSize,
+ duration,
+ fileExtension: fileName.split('.').pop()?.toLowerCase(),
+ });
+ }
+
+ static logDownloadError(filePath: string, fileName: string, error: string, duration?: number) {
+ this.logToServer('download_error', {
+ filePath,
+ fileName,
+ error,
+ duration,
+ fileExtension: fileName.split('.').pop()?.toLowerCase(),
+ });
+ }
+
+ static logSecurityViolation(type: string, details: any) {
+ this.logToServer('security_violation', {
+ type,
+ ...details,
+ });
+ }
+}
+
+/**
+ * 보안 검증 함수들
+ */
+const SecurityValidator = {
+ validateFileExtension(fileName: string): boolean {
+ const extension = fileName.split('.').pop()?.toLowerCase();
+ return extension ? SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension) : false;
+ },
+
+ validateFileName(fileName: string): boolean {
+ // 위험한 문자 체크
+ const dangerousPatterns = [
+ /[<>:"'|?*]/, // 특수문자
+ /[\x00-\x1f]/, // 제어문자
+ /^\./, // 숨김 파일
+ /\.(exe|bat|cmd|scr|vbs|js|jar)$/i, // 실행 파일
+ ];
+
+ return !dangerousPatterns.some(pattern => pattern.test(fileName));
+ },
+
+ validateFilePath(filePath: string): boolean {
+ // 경로 탐색 공격 방지
+ const dangerousPatterns = [
+ /\.\./, // 상위 디렉토리 접근
+ /\/\//, // 이중 슬래시
+ /\\+/, // 백슬래시
+ /[<>:"'|?*]/, // 특수문자
+ ];
+
+ return !dangerousPatterns.some(pattern => pattern.test(filePath));
+ },
+
+ validateUrl(url: string): boolean {
+ try {
+ const urlObj = new URL(url);
+
+ // HTTPS 강제 (개발 환경 제외)
+ if (window.location.protocol === 'https:' && urlObj.protocol !== 'https:') {
+ if (!['localhost', '127.0.0.1'].includes(urlObj.hostname)) {
+ return false;
+ }
+ }
+
+ // 허용된 도메인 검사 (상대 URL은 허용)
+ if (urlObj.hostname && !SECURITY_CONFIG.ALLOWED_DOMAINS.includes(urlObj.hostname)) {
+ return false;
+ }
+
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ validateFileSize(size: number): boolean {
+ return size <= SECURITY_CONFIG.MAX_FILE_SIZE;
+ },
+};
+
+/**
* 파일 정보 가져오기
*/
export const getFileInfo = (fileName: string): FileInfo => {
@@ -57,32 +291,24 @@ export const formatFileSize = (bytes: number): string => {
};
/**
- * 파일 다운로드 옵션
+ * 타임아웃이 적용된 fetch
*/
-export interface FileDownloadOptions {
- /** 다운로드 액션 타입 */
- action?: 'download' | 'preview';
- /** 에러 시 토스트 표시 여부 */
- showToast?: boolean;
- /** 성공 시 토스트 표시 여부 */
- showSuccessToast?: boolean;
- /** 커스텀 에러 핸들러 */
- onError?: (error: string) => void;
- /** 커스텀 성공 핸들러 */
- onSuccess?: (fileName: string, fileSize?: number) => void;
- /** 진행률 콜백 (큰 파일용) */
- onProgress?: (progress: number) => void;
-}
-
-/**
- * 파일 다운로드 결과
- */
-export interface FileDownloadResult {
- success: boolean;
- error?: string;
- fileSize?: number;
- fileInfo?: FileInfo;
-}
+const fetchWithTimeout = async (url: string, options: RequestInit = {}): Promise<Response> => {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), SECURITY_CONFIG.FETCH_TIMEOUT);
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ return response;
+ } catch (error) {
+ clearTimeout(timeoutId);
+ throw error;
+ }
+};
/**
* 파일 메타데이터 확인
@@ -95,7 +321,12 @@ export const checkFileMetadata = async (url: string): Promise<{
error?: string;
}> => {
try {
- const response = await fetch(url, {
+ // URL 보안 검증
+ if (!SecurityValidator.validateUrl(url)) {
+ return { exists: false, error: "허용되지 않은 URL입니다" };
+ }
+
+ const response = await fetchWithTimeout(url, {
method: 'HEAD',
headers: { 'Cache-Control': 'no-cache' }
});
@@ -110,6 +341,9 @@ export const checkFileMetadata = async (url: string): Promise<{
case 403:
error = "파일 접근 권한이 없습니다";
break;
+ case 429:
+ error = "요청이 너무 많습니다. 잠시 후 다시 시도해주세요";
+ break;
case 500:
error = "서버 오류가 발생했습니다";
break;
@@ -123,18 +357,26 @@ export const checkFileMetadata = async (url: string): Promise<{
const contentLength = response.headers.get('Content-Length');
const contentType = response.headers.get('Content-Type');
const lastModified = response.headers.get('Last-Modified');
+
+ const size = contentLength ? parseInt(contentLength, 10) : undefined;
+
+ // 파일 크기 검증
+ if (size && !SecurityValidator.validateFileSize(size)) {
+ return {
+ exists: false,
+ error: `파일이 너무 큽니다 (최대 ${formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE)})`
+ };
+ }
return {
exists: true,
- size: contentLength ? parseInt(contentLength, 10) : undefined,
+ size,
contentType: contentType || undefined,
lastModified: lastModified ? new Date(lastModified) : undefined,
};
} catch (error) {
- return {
- exists: false,
- error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다"
- };
+ const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다";
+ return { exists: false, error: errorMessage };
}
};
@@ -146,85 +388,199 @@ export const downloadFile = async (
fileName: string,
options: FileDownloadOptions = {}
): Promise<FileDownloadResult> => {
- const { action = 'download', showToast = true, onError, onSuccess } = options;
+ const startTime = Date.now();
+ const { action = 'download', showToast = true, onError, onSuccess, disableLogging = false } = options;
+
+ // 로깅
+ if (!disableLogging) {
+ ClientLogger.logDownloadAttempt(filePath, fileName, action, {
+ userInitiated: true,
+ disableLogging
+ });
+ }
try {
- // ✅ URL에 다운로드 강제 파라미터 추가
+ // Rate limiting 체크
+ if (!rateLimiter.canDownload()) {
+ const error = `다운로드 제한 초과. ${rateLimiter.getRemainingDownloads()}회 남음`;
+ if (showToast) toast.error(error);
+ if (onError) onError(error);
+ if (!disableLogging) {
+ ClientLogger.logSecurityViolation('rate_limit_exceeded', { filePath, fileName });
+ }
+ return { success: false, error };
+ }
+
+ // 보안 검증
+ if (!SecurityValidator.validateFileName(fileName)) {
+ const error = "안전하지 않은 파일명입니다";
+ if (showToast) toast.error(error);
+ if (onError) onError(error);
+ if (!disableLogging) {
+ ClientLogger.logSecurityViolation('invalid_filename', { filePath, fileName });
+ }
+ return { success: false, error };
+ }
+
+ if (!SecurityValidator.validateFilePath(filePath)) {
+ const error = "안전하지 않은 파일 경로입니다";
+ if (showToast) toast.error(error);
+ if (onError) onError(error);
+ if (!disableLogging) {
+ ClientLogger.logSecurityViolation('invalid_filepath', { filePath, fileName });
+ }
+ return { success: false, error };
+ }
+
+ if (!SecurityValidator.validateFileExtension(fileName)) {
+ const error = "허용되지 않은 파일 형식입니다";
+ if (showToast) toast.error(error);
+ if (onError) onError(error);
+ if (!disableLogging) {
+ ClientLogger.logSecurityViolation('invalid_extension', { filePath, fileName });
+ }
+ return { success: false, error };
+ }
+
+ // URL 구성
const baseUrl = filePath.startsWith('http')
? filePath
: `${window.location.origin}${filePath}`;
const url = new URL(baseUrl);
if (action === 'download') {
- url.searchParams.set('download', 'true'); // 🔑 핵심!
+ url.searchParams.set('download', 'true');
}
const fullUrl = url.toString();
+ // URL 보안 검증
+ if (!SecurityValidator.validateUrl(fullUrl)) {
+ const error = "허용되지 않은 URL입니다";
+ if (showToast) toast.error(error);
+ if (onError) onError(error);
+ if (!disableLogging) {
+ ClientLogger.logSecurityViolation('invalid_url', { fullUrl, fileName });
+ }
+ return { success: false, error };
+ }
+
// 파일 정보 확인
const metadata = await checkFileMetadata(fullUrl);
if (!metadata.exists) {
const error = metadata.error || "파일을 찾을 수 없습니다";
if (showToast) toast.error(error);
if (onError) onError(error);
- return { success: false, error };
+ const duration = Date.now() - startTime;
+ if (!disableLogging) {
+ ClientLogger.logDownloadError(filePath, fileName, error, duration);
+ }
+ return { success: false, error, downloadDuration: duration };
}
const fileInfo = getFileInfo(fileName);
- // 미리보기 처리 (download=true 없이)
+ // 미리보기 처리
if (action === 'preview' && fileInfo.canPreview) {
const previewUrl = filePath.startsWith('http')
? filePath
: `${window.location.origin}${filePath}`;
- window.open(previewUrl, '_blank', 'noopener,noreferrer');
- if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`);
- if (onSuccess) onSuccess(fileName, metadata.size);
- return { success: true, fileSize: metadata.size, fileInfo };
+ // 안전한 새 창 열기
+ const newWindow = window.open('', '_blank', 'noopener,noreferrer');
+ if (newWindow) {
+ newWindow.location.href = previewUrl;
+ if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`);
+ if (onSuccess) onSuccess(fileName, metadata.size);
+ const duration = Date.now() - startTime;
+ if (!disableLogging) {
+ ClientLogger.logDownloadSuccess(filePath, fileName, metadata.size, duration);
+ }
+ return { success: true, fileSize: metadata.size, fileInfo, downloadDuration: duration };
+ } else {
+ throw new Error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
+ }
}
- // ✅ 안전한 다운로드 방식 (fetch + Blob)
- console.log(`📥 안전한 다운로드: ${fullUrl}`);
+ // 안전한 다운로드
+ console.log(`📥 보안 검증된 다운로드: ${fullUrl}`);
- const response = await fetch(fullUrl);
+ const response = await fetchWithTimeout(fullUrl);
if (!response.ok) {
throw new Error(`다운로드 실패: ${response.status}`);
}
+ // Content-Type 검증
+ const contentType = response.headers.get('Content-Type');
+ if (contentType && fileInfo.mimeType && !contentType.includes(fileInfo.mimeType.split(';')[0])) {
+ console.warn('⚠️ MIME 타입 불일치:', { expected: fileInfo.mimeType, actual: contentType });
+ }
+
// Blob으로 변환
const blob = await response.blob();
- // ✅ 브라우저 호환성을 고려한 다운로드
+ // 최종 파일 크기 검증
+ if (!SecurityValidator.validateFileSize(blob.size)) {
+ const error = `파일이 너무 큽니다 (최대 ${formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE)})`;
+ if (showToast) toast.error(error);
+ if (onError) onError(error);
+ const duration = Date.now() - startTime;
+ if (!disableLogging) {
+ ClientLogger.logDownloadError(filePath, fileName, error, duration);
+ }
+ return { success: false, error, downloadDuration: duration };
+ }
+
+ // 브라우저 호환성을 고려한 안전한 다운로드
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = fileName;
- link.style.display = 'none'; // 화면에 표시되지 않도록
+ link.style.display = 'none';
+ link.setAttribute('data-download-source', 'secure-download-utility');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
- // 메모리 정리 (중요!)
+ // 메모리 정리
setTimeout(() => URL.revokeObjectURL(downloadUrl), 100);
// 성공 처리
+ const duration = Date.now() - startTime;
if (showToast) {
- const sizeText = metadata.size ? ` (${formatFileSize(metadata.size)})` : '';
+ const sizeText = blob.size ? ` (${formatFileSize(blob.size)})` : '';
toast.success(`${fileInfo.icon} 파일 다운로드 완료: ${fileName}${sizeText}`);
}
- if (onSuccess) onSuccess(fileName, metadata.size);
+ if (onSuccess) onSuccess(fileName, blob.size);
+ if (!disableLogging) {
+ ClientLogger.logDownloadSuccess(filePath, fileName, blob.size, duration);
+ }
- return { success: true, fileSize: metadata.size, fileInfo };
+ return {
+ success: true,
+ fileSize: blob.size,
+ fileInfo,
+ downloadDuration: duration
+ };
} catch (error) {
+ const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다";
+
console.error("❌ 다운로드 오류:", error);
if (showToast) toast.error(errorMessage);
if (onError) onError(errorMessage);
- return { success: false, error: errorMessage };
+ if (!disableLogging) {
+ ClientLogger.logDownloadError(filePath, fileName, errorMessage, duration);
+ }
+
+ return {
+ success: false,
+ error: errorMessage,
+ downloadDuration: duration
+ };
}
};
@@ -257,4 +613,17 @@ export const smartFileAction = (filePath: string, fileName: string) => {
const action = fileInfo.canPreview ? 'preview' : 'download';
return downloadFile(filePath, fileName, { action });
+};
+
+/**
+ * 보안 정보 조회
+ */
+export const getSecurityInfo = () => {
+ return {
+ allowedExtensions: Array.from(SECURITY_CONFIG.ALLOWED_EXTENSIONS),
+ maxFileSize: SECURITY_CONFIG.MAX_FILE_SIZE,
+ maxFileSizeFormatted: formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE),
+ remainingDownloads: rateLimiter.getRemainingDownloads(),
+ allowedDomains: SECURITY_CONFIG.ALLOWED_DOMAINS,
+ };
}; \ No newline at end of file
diff --git a/lib/file-stroage.ts b/lib/file-stroage.ts
index ae84f506..beca05ee 100644
--- a/lib/file-stroage.ts
+++ b/lib/file-stroage.ts
@@ -1,8 +1,9 @@
-// lib/file-storage.ts - File과 ArrayBuffer를 위한 분리된 함수들
+// lib/file-storage.ts - 보안이 강화된 파일 저장 유틸리티
import { promises as fs } from "fs";
import path from "path";
import crypto from "crypto";
+import { createHash } from "crypto";
interface FileStorageConfig {
baseDir: string;
@@ -10,6 +11,309 @@ interface FileStorageConfig {
isProduction: boolean;
}
+// 보안 설정
+const SECURITY_CONFIG = {
+ // 허용된 파일 확장자
+ ALLOWED_EXTENSIONS: new Set([
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
+ 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp',
+ // SVG 제거 - XSS 위험으로 인해
+ // 'svg',
+ 'dwg', 'dxf', 'zip', 'rar', '7z'
+ ]),
+
+ // 금지된 파일 확장자 (실행 파일 등)
+ FORBIDDEN_EXTENSIONS: new Set([
+ 'exe', 'bat', 'cmd', 'scr', 'vbs', 'js', 'jar', 'com', 'pif',
+ 'msi', 'reg', 'ps1', 'sh', 'php', 'asp', 'jsp', 'py', 'pl',
+ // XSS 방지를 위한 추가 확장자
+ 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt'
+ ]),
+
+ // 허용된 MIME 타입
+ ALLOWED_MIME_TYPES: new Set([
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp',
+ // SVG 제거 - XSS 위험으로 인해
+ // 'image/svg+xml',
+ 'text/plain', 'text/csv',
+ 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed'
+ ]),
+
+ // 최대 파일 크기 (100MB)
+ MAX_FILE_SIZE: 100 * 1024 * 1024,
+
+ // 파일명 최대 길이
+ MAX_FILENAME_LENGTH: 255,
+};
+
+// 보안 검증 클래스
+class FileSecurityValidator {
+ // 파일 확장자 검증
+ static validateExtension(fileName: string): { valid: boolean; error?: string } {
+ const extension = path.extname(fileName).toLowerCase().substring(1);
+
+ if (!extension) {
+ return { valid: false, error: "파일 확장자가 없습니다" };
+ }
+
+ if (SECURITY_CONFIG.FORBIDDEN_EXTENSIONS.has(extension)) {
+ return { valid: false, error: `금지된 파일 형식입니다: .${extension}` };
+ }
+
+ if (!SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension)) {
+ return { valid: false, error: `허용되지 않은 파일 형식입니다: .${extension}` };
+ }
+
+ return { valid: true };
+ }
+
+ // 파일명 안전성 검증
+ static validateFileName(fileName: string): { valid: boolean; error?: string } {
+ // 길이 체크
+ if (fileName.length > SECURITY_CONFIG.MAX_FILENAME_LENGTH) {
+ return { valid: false, error: "파일명이 너무 깁니다" };
+ }
+
+ // 위험한 문자 체크 (XSS 방지 강화)
+ const dangerousPatterns = [
+ /[<>:"'|?*]/, // HTML 태그 및 특수문자
+ /[\x00-\x1f]/, // 제어문자
+ /^\./, // 숨김 파일
+ /\.\./, // 상위 디렉토리 접근
+ /\/|\\$/, // 경로 구분자
+ /javascript:/i, // JavaScript 프로토콜
+ /data:/i, // Data URI
+ /vbscript:/i, // VBScript 프로토콜
+ /on\w+=/i, // 이벤트 핸들러 (onclick=, onload= 등)
+ /<script/i, // Script 태그
+ /<iframe/i, // Iframe 태그
+ ];
+
+ for (const pattern of dangerousPatterns) {
+ if (pattern.test(fileName)) {
+ return { valid: false, error: "안전하지 않은 파일명입니다" };
+ }
+ }
+
+ // 예약된 Windows 파일명 체크
+ const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
+ const nameWithoutExt = path.basename(fileName, path.extname(fileName)).toUpperCase();
+
+ if (reservedNames.includes(nameWithoutExt)) {
+ return { valid: false, error: "예약된 파일명입니다" };
+ }
+
+ return { valid: true };
+ }
+
+ // 파일 크기 검증
+ static validateFileSize(size: number): { valid: boolean; error?: string } {
+ if (size <= 0) {
+ return { valid: false, error: "파일이 비어있습니다" };
+ }
+
+ if (size > SECURITY_CONFIG.MAX_FILE_SIZE) {
+ const maxSizeMB = Math.round(SECURITY_CONFIG.MAX_FILE_SIZE / (1024 * 1024));
+ return { valid: false, error: `파일 크기가 너무 큽니다 (최대 ${maxSizeMB}MB)` };
+ }
+
+ return { valid: true };
+ }
+
+ // MIME 타입 검증
+ static validateMimeType(mimeType: string, fileName: string): { valid: boolean; error?: string } {
+ if (!mimeType) {
+ return { valid: false, error: "MIME 타입을 확인할 수 없습니다" };
+ }
+
+ // 기본 MIME 타입 체크
+ const baseMimeType = mimeType.split(';')[0].toLowerCase();
+
+ if (!SECURITY_CONFIG.ALLOWED_MIME_TYPES.has(baseMimeType)) {
+ return { valid: false, error: `허용되지 않은 파일 형식입니다: ${baseMimeType}` };
+ }
+
+ // 확장자와 MIME 타입 일치성 체크
+ const extension = path.extname(fileName).toLowerCase().substring(1);
+ const expectedMimeTypes = this.getExpectedMimeTypes(extension);
+
+ if (expectedMimeTypes.length > 0 && !expectedMimeTypes.includes(baseMimeType)) {
+ console.warn(`⚠️ MIME 타입 불일치: ${fileName} (확장자: ${extension}, MIME: ${baseMimeType})`);
+ // 경고만 하고 허용 (일부 브라우저에서 MIME 타입이 다를 수 있음)
+ }
+
+ return { valid: true };
+ }
+
+ // 확장자별 예상되는 MIME 타입들
+ private static getExpectedMimeTypes(extension: string): string[] {
+ const mimeMap: Record<string, string[]> = {
+ 'pdf': ['application/pdf'],
+ 'doc': ['application/msword'],
+ 'docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
+ 'xls': ['application/vnd.ms-excel'],
+ 'xlsx': ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
+ 'jpg': ['image/jpeg'],
+ 'jpeg': ['image/jpeg'],
+ 'png': ['image/png'],
+ 'gif': ['image/gif'],
+ 'bmp': ['image/bmp'],
+ 'svg': ['image/svg+xml'],
+ 'webp': ['image/webp'],
+ 'txt': ['text/plain'],
+ 'csv': ['text/csv', 'application/csv'],
+ 'zip': ['application/zip'],
+ 'rar': ['application/x-rar-compressed'],
+ '7z': ['application/x-7z-compressed'],
+ };
+
+ return mimeMap[extension] || [];
+ }
+
+ // 디렉터리 기본 안전성 검증 (경로 탐색 공격 방지)
+ static validateDirectory(directory: string): { valid: boolean; error?: string } {
+ // 경로 정규화
+ const normalizedDir = path.normalize(directory).replace(/^\/+/, '');
+
+ // 경로 탐색 공격 방지
+ if (normalizedDir.includes('..') || normalizedDir.includes('//')) {
+ return { valid: false, error: "안전하지 않은 디렉터리 경로입니다" };
+ }
+
+ // 절대 경로 방지
+ if (path.isAbsolute(directory)) {
+ return { valid: false, error: "절대 경로는 사용할 수 없습니다" };
+ }
+
+ return { valid: true };
+ }
+
+ // 파일 내용 기본 검증 (매직 넘버 체크 + XSS 패턴 검사)
+ static async validateFileContent(buffer: Buffer, fileName: string): Promise<{ valid: boolean; error?: string }> {
+ try {
+ const extension = path.extname(fileName).toLowerCase().substring(1);
+
+ // 파일 시그니처 (매직 넘버) 검증
+ const fileSignatures: Record<string, Buffer[]> = {
+ 'pdf': [Buffer.from([0x25, 0x50, 0x44, 0x46])], // %PDF
+ 'jpg': [Buffer.from([0xFF, 0xD8, 0xFF])],
+ 'jpeg': [Buffer.from([0xFF, 0xD8, 0xFF])],
+ 'png': [Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])],
+ 'gif': [Buffer.from([0x47, 0x49, 0x46, 0x38])], // GIF8
+ 'zip': [Buffer.from([0x50, 0x4B, 0x03, 0x04]), Buffer.from([0x50, 0x4B, 0x05, 0x06])],
+ };
+
+ const expectedSignatures = fileSignatures[extension];
+ if (expectedSignatures) {
+ const hasValidSignature = expectedSignatures.some(signature =>
+ buffer.subarray(0, signature.length).equals(signature)
+ );
+
+ if (!hasValidSignature) {
+ return { valid: false, error: `파일 내용이 확장자와 일치하지 않습니다: ${extension}` };
+ }
+ }
+
+ // 실행 파일 패턴 검색
+ const executablePatterns = [
+ Buffer.from([0x4D, 0x5A]), // MZ (Windows executable)
+ Buffer.from([0x7F, 0x45, 0x4C, 0x46]), // ELF (Linux executable)
+ ];
+
+ for (const pattern of executablePatterns) {
+ if (buffer.subarray(0, pattern.length).equals(pattern)) {
+ return { valid: false, error: "실행 파일은 업로드할 수 없습니다" };
+ }
+ }
+
+ // XSS 패턴 검사 (텍스트 기반 파일용)
+ const textBasedExtensions = ['txt', 'csv', 'xml', 'svg', 'html', 'htm'];
+ if (textBasedExtensions.includes(extension)) {
+ const content = buffer.toString('utf8', 0, Math.min(buffer.length, 8192)); // 첫 8KB만 검사
+
+ const xssPatterns = [
+ /<script[\s\S]*?>/i, // <script> 태그
+ /<iframe[\s\S]*?>/i, // <iframe> 태그
+ /on\w+\s*=\s*["'][^"']*["']/i, // 이벤트 핸들러 (onclick="...")
+ /javascript\s*:/i, // javascript: 프로토콜
+ /vbscript\s*:/i, // vbscript: 프로토콜
+ /data\s*:\s*text\/html/i, // data:text/html
+ /<meta[\s\S]*?http-equiv[\s\S]*?>/i, // meta refresh
+ /<object[\s\S]*?>/i, // object 태그
+ /<embed[\s\S]*?>/i, // embed 태그
+ /<form[\s\S]*?action[\s\S]*?>/i, // form 태그
+ ];
+
+ for (const pattern of xssPatterns) {
+ if (pattern.test(content)) {
+ return { valid: false, error: "파일에 잠재적으로 위험한 스크립트가 포함되어 있습니다" };
+ }
+ }
+ }
+
+ return { valid: true };
+ } catch (error) {
+ console.error("파일 내용 검증 오류:", error);
+ return { valid: false, error: "파일 내용을 검증할 수 없습니다" };
+ }
+ }
+}
+
+// 파일 업로드 로깅 클래스
+class FileUploadLogger {
+ static logUploadAttempt(fileName: string, size: number, directory: string, userId?: string) {
+ console.log(`📤 파일 업로드 시도:`, {
+ fileName,
+ size: this.formatFileSize(size),
+ directory,
+ userId,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ static logUploadSuccess(fileName: string, hashedFileName: string, size: number, directory: string, userId?: string) {
+ console.log(`✅ 파일 업로드 성공:`, {
+ originalName: fileName,
+ savedName: hashedFileName,
+ size: this.formatFileSize(size),
+ directory,
+ userId,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ static logUploadError(fileName: string, error: string, userId?: string) {
+ console.error(`❌ 파일 업로드 실패:`, {
+ fileName,
+ error,
+ userId,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ static logSecurityViolation(fileName: string, violation: string, userId?: string) {
+ console.warn(`🚨 파일 업로드 보안 위반:`, {
+ fileName,
+ violation,
+ userId,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ private static formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+}
+
// 파일명 해시 생성 유틸리티
export function generateHashedFileName(originalName: string): string {
const fileExtension = path.extname(originalName);
@@ -24,19 +328,43 @@ export function generateHashedFileName(originalName: string): string {
return `${timestamp}-${randomHash}${fileExtension}`;
}
-// ✅ File 저장용 인터페이스
+// HTML safe한 파일명 생성 (XSS 방지)
+export function sanitizeFileNameForDisplay(fileName: string): string {
+ return fileName
+ .replace(/&/g, '&amp;') // & → &amp;
+ .replace(/</g, '&lt;') // < → &lt;
+ .replace(/>/g, '&gt;') // > → &gt;
+ .replace(/"/g, '&quot;') // " → &quot;
+ .replace(/'/g, '&#39;') // ' → &#39;
+ .replace(/\//g, '&#47;') // / → &#47;
+ .replace(/\\/g, '&#92;'); // \ → &#92;
+}
+
+// 파일명에서 위험한 문자 제거 (저장용)
+export function sanitizeFileNameForStorage(fileName: string): string {
+ return fileName
+ .replace(/[<>:"'|?*\\\/]/g, '_') // 위험한 문자를 언더스코어로
+ .replace(/[\x00-\x1f]/g, '') // 제어문자 제거
+ .replace(/\s+/g, '_') // 공백을 언더스코어로
+ .replace(/_{2,}/g, '_') // 연속된 언더스코어를 하나로
+ .replace(/^_+|_+$/g, '') // 앞뒤 언더스코어 제거
+ .substring(0, 200); // 길이 제한
+}
+
+// 보안 강화된 파일 저장 옵션들
interface SaveFileOptions {
file: File;
directory: string;
originalName?: string;
+ userId?: string;
}
-// ✅ Buffer/ArrayBuffer 저장용 인터페이스
interface SaveBufferOptions {
buffer: Buffer | ArrayBuffer;
fileName: string;
directory: string;
originalName?: string;
+ userId?: string;
}
interface SaveFileResult {
@@ -44,10 +372,19 @@ interface SaveFileResult {
filePath?: string;
publicPath?: string;
fileName?: string;
+ originalName?: string;
+ fileSize?: number;
error?: string;
+ securityChecks?: {
+ extensionCheck: boolean;
+ fileNameCheck: boolean;
+ sizeCheck: boolean;
+ mimeTypeCheck: boolean;
+ contentCheck: boolean;
+ };
}
-const nasPath = process.env.NAS_PATH || "/evcp_nas"
+const nasPath = process.env.NAS_PATH || "/evcp_nas";
// 환경별 설정
function getStorageConfig(): FileStorageConfig {
@@ -68,22 +405,87 @@ function getStorageConfig(): FileStorageConfig {
}
}
-// ✅ 1. File 객체 저장 함수 (기존 방식)
+// 보안이 강화된 File 객체 저장 함수
export async function saveFile({
file,
directory,
- originalName
+ originalName,
+ userId,
}: SaveFileOptions): Promise<SaveFileResult> {
+ const finalFileName = originalName || file.name;
+
+ // 초기 로깅
+ FileUploadLogger.logUploadAttempt(finalFileName, file.size, directory, userId);
+
try {
const config = getStorageConfig();
- const finalFileName = originalName || file.name;
- const hashedFileName = generateHashedFileName(finalFileName);
+ const securityChecks = {
+ extensionCheck: false,
+ fileNameCheck: false,
+ sizeCheck: false,
+ mimeTypeCheck: false,
+ contentCheck: false,
+ };
+
+ // 1. 디렉터리 기본 안전성 검증
+ const dirValidation = FileSecurityValidator.validateDirectory(directory);
+ if (!dirValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Directory: ${dirValidation.error}`, userId);
+ return { success: false, error: dirValidation.error, securityChecks };
+ }
+
+ // 2. 파일 확장자 검증
+ const extValidation = FileSecurityValidator.validateExtension(finalFileName);
+ if (!extValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Extension: ${extValidation.error}`, userId);
+ return { success: false, error: extValidation.error, securityChecks };
+ }
+ securityChecks.extensionCheck = true;
+
+ // 3. 파일명 안전성 검증
+ const nameValidation = FileSecurityValidator.validateFileName(finalFileName);
+ if (!nameValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `FileName: ${nameValidation.error}`, userId);
+ return { success: false, error: nameValidation.error, securityChecks };
+ }
+ securityChecks.fileNameCheck = true;
+
+ // 4. 파일 크기 검증
+ const sizeValidation = FileSecurityValidator.validateFileSize(file.size);
+ if (!sizeValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Size: ${sizeValidation.error}`, userId);
+ return { success: false, error: sizeValidation.error, securityChecks };
+ }
+ securityChecks.sizeCheck = true;
+
+ // 5. MIME 타입 검증
+ const mimeValidation = FileSecurityValidator.validateMimeType(file.type, finalFileName);
+ if (!mimeValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `MIME: ${mimeValidation.error}`, userId);
+ return { success: false, error: mimeValidation.error, securityChecks };
+ }
+ securityChecks.mimeTypeCheck = true;
+
+ // 6. 파일 내용 추출 및 검증
+ const arrayBuffer = await file.arrayBuffer();
+ const dataBuffer = Buffer.from(arrayBuffer);
+
+ const contentValidation = await FileSecurityValidator.validateFileContent(dataBuffer, finalFileName);
+ if (!contentValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Content: ${contentValidation.error}`, userId);
+ return { success: false, error: contentValidation.error, securityChecks };
+ }
+ securityChecks.contentCheck = true;
+
+ // 7. 안전한 파일명 전처리
+ const safeOriginalName = sanitizeFileNameForStorage(finalFileName);
+ const hashedFileName = generateHashedFileName(safeOriginalName);
- // 저장 경로 설정
+ // 8. 저장 경로 설정
const saveDir = path.join(config.baseDir, directory);
const filePath = path.join(saveDir, hashedFileName);
- // 웹 접근 경로
+ // 9. 웹 접근 경로
let publicPath: string;
if (config.isProduction) {
publicPath = `${config.publicUrl}/${directory}/${hashedFileName}`;
@@ -91,54 +493,110 @@ export async function saveFile({
publicPath = `/${directory}/${hashedFileName}`;
}
- console.log(`📄 File 객체 저장: ${finalFileName}`);
+ console.log(`📄 보안 검증 완료 - File 객체 저장: ${finalFileName}`);
console.log(`📁 저장 위치: ${filePath}`);
console.log(`🌐 웹 접근 경로: ${publicPath}`);
- // 디렉토리 생성
+ // 10. 디렉토리 생성
await fs.mkdir(saveDir, { recursive: true });
- // File 객체에서 데이터 추출
- const arrayBuffer = await file.arrayBuffer();
- const dataBuffer = Buffer.from(arrayBuffer);
-
- // 파일 저장
+ // 11. 파일 저장
await fs.writeFile(filePath, dataBuffer);
- console.log(`✅ File 저장 완료: ${hashedFileName} (${dataBuffer.length} bytes)`);
+ // 12. 성공 로깅
+ FileUploadLogger.logUploadSuccess(finalFileName, hashedFileName, file.size, directory, userId);
return {
success: true,
filePath,
publicPath,
fileName: hashedFileName,
+ originalName: finalFileName,
+ fileSize: file.size,
+ securityChecks,
};
} catch (error) {
- console.error("File 저장 실패:", error);
+ const errorMessage = error instanceof Error ? error.message : "File 저장 중 오류가 발생했습니다.";
+ FileUploadLogger.logUploadError(finalFileName, errorMessage, userId);
return {
success: false,
- error: error instanceof Error ? error.message : "File 저장 중 오류가 발생했습니다.",
+ error: errorMessage,
};
}
}
-// ✅ 2. Buffer/ArrayBuffer 저장 함수 (DRM 복호화용)
+// 보안이 강화된 Buffer 저장 함수
export async function saveBuffer({
buffer,
fileName,
directory,
- originalName
+ originalName,
+ userId,
}: SaveBufferOptions): Promise<SaveFileResult> {
+ const finalFileName = originalName || fileName;
+ const dataBuffer = buffer instanceof ArrayBuffer ? Buffer.from(buffer) : buffer;
+
+ // 초기 로깅
+ FileUploadLogger.logUploadAttempt(finalFileName, dataBuffer.length, directory, userId);
+
try {
const config = getStorageConfig();
- const finalFileName = originalName || fileName;
- const hashedFileName = generateHashedFileName(finalFileName);
+ const securityChecks = {
+ extensionCheck: false,
+ fileNameCheck: false,
+ sizeCheck: false,
+ mimeTypeCheck: true, // Buffer는 MIME 타입 검증 스킵
+ contentCheck: false,
+ };
+
+ // 1. 디렉터리 기본 안전성 검증
+ const dirValidation = FileSecurityValidator.validateDirectory(directory);
+ if (!dirValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Directory: ${dirValidation.error}`, userId);
+ return { success: false, error: dirValidation.error, securityChecks };
+ }
+
+ // 2. 파일 확장자 검증
+ const extValidation = FileSecurityValidator.validateExtension(finalFileName);
+ if (!extValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Extension: ${extValidation.error}`, userId);
+ return { success: false, error: extValidation.error, securityChecks };
+ }
+ securityChecks.extensionCheck = true;
+
+ // 3. 파일명 안전성 검증
+ const nameValidation = FileSecurityValidator.validateFileName(finalFileName);
+ if (!nameValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `FileName: ${nameValidation.error}`, userId);
+ return { success: false, error: nameValidation.error, securityChecks };
+ }
+ securityChecks.fileNameCheck = true;
+
+ // 4. 파일 크기 검증
+ const sizeValidation = FileSecurityValidator.validateFileSize(dataBuffer.length);
+ if (!sizeValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Size: ${sizeValidation.error}`, userId);
+ return { success: false, error: sizeValidation.error, securityChecks };
+ }
+ securityChecks.sizeCheck = true;
+
+ // 5. 파일 내용 검증
+ const contentValidation = await FileSecurityValidator.validateFileContent(dataBuffer, finalFileName);
+ if (!contentValidation.valid) {
+ FileUploadLogger.logSecurityViolation(finalFileName, `Content: ${contentValidation.error}`, userId);
+ return { success: false, error: contentValidation.error, securityChecks };
+ }
+ securityChecks.contentCheck = true;
- // 저장 경로 설정
+ // 6. 안전한 파일명 전처리
+ const safeOriginalName = sanitizeFileNameForStorage(finalFileName);
+ const hashedFileName = generateHashedFileName(safeOriginalName);
+
+ // 7. 저장 경로 설정
const saveDir = path.join(config.baseDir, directory);
const filePath = path.join(saveDir, hashedFileName);
- // 웹 접근 경로
+ // 8. 웹 접근 경로
let publicPath: string;
if (config.isProduction) {
publicPath = `${config.publicUrl}/${directory}/${hashedFileName}`;
@@ -146,37 +604,39 @@ export async function saveBuffer({
publicPath = `/${directory}/${hashedFileName}`;
}
- console.log(`🔓 Buffer/ArrayBuffer 저장: ${finalFileName}`);
+ console.log(`🔓 보안 검증 완료 - Buffer 저장: ${finalFileName}`);
console.log(`📁 저장 위치: ${filePath}`);
console.log(`🌐 웹 접근 경로: ${publicPath}`);
- // 디렉토리 생성
+ // 9. 디렉토리 생성
await fs.mkdir(saveDir, { recursive: true });
- // Buffer 준비
- const dataBuffer = buffer instanceof ArrayBuffer ? Buffer.from(buffer) : buffer;
-
- // 파일 저장
+ // 10. 파일 저장
await fs.writeFile(filePath, dataBuffer);
- console.log(`✅ Buffer 저장 완료: ${hashedFileName} (${dataBuffer.length} bytes)`);
+ // 11. 성공 로깅
+ FileUploadLogger.logUploadSuccess(finalFileName, hashedFileName, dataBuffer.length, directory, userId);
return {
success: true,
filePath,
publicPath,
fileName: hashedFileName,
+ originalName: finalFileName,
+ fileSize: dataBuffer.length,
+ securityChecks,
};
} catch (error) {
- console.error("Buffer 저장 실패:", error);
+ const errorMessage = error instanceof Error ? error.message : "Buffer 저장 중 오류가 발생했습니다.";
+ FileUploadLogger.logUploadError(finalFileName, errorMessage, userId);
return {
success: false,
- error: error instanceof Error ? error.message : "Buffer 저장 중 오류가 발생했습니다.",
+ error: errorMessage,
};
}
}
-// ✅ 업데이트 함수들
+// 업데이트 함수들 (보안 검증 포함)
export async function updateFile(
options: SaveFileOptions,
oldFilePath?: string
@@ -190,11 +650,9 @@ export async function updateFile(
return result;
} catch (error) {
- console.error("File 업데이트 실패:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "File 업데이트 중 오류가 발생했습니다.",
- };
+ const errorMessage = error instanceof Error ? error.message : "File 업데이트 중 오류가 발생했습니다.";
+ FileUploadLogger.logUploadError(options.originalName || options.file.name, errorMessage, options.userId);
+ return { success: false, error: errorMessage };
}
}
@@ -211,15 +669,13 @@ export async function updateBuffer(
return result;
} catch (error) {
- console.error("Buffer 업데이트 실패:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "Buffer 업데이트 중 오류가 발생했습니다.",
- };
+ const errorMessage = error instanceof Error ? error.message : "Buffer 업데이트 중 오류가 발생했습니다.";
+ FileUploadLogger.logUploadError(options.originalName || options.fileName, errorMessage, options.userId);
+ return { success: false, error: errorMessage };
}
}
-// 파일 삭제 함수
+// 안전한 파일 삭제 함수
export async function deleteFile(publicPath: string): Promise<boolean> {
try {
const config = getStorageConfig();
@@ -232,6 +688,13 @@ export async function deleteFile(publicPath: string): Promise<boolean> {
absolutePath = path.join(process.cwd(), 'public', publicPath);
}
+ // 경로 안전성 검증
+ const normalizedPath = path.normalize(absolutePath);
+ if (normalizedPath.includes('..')) {
+ console.error("🚨 위험한 파일 삭제 시도:", absolutePath);
+ return false;
+ }
+
console.log(`🗑️ 파일 삭제: ${absolutePath}`);
await fs.access(absolutePath);
@@ -243,17 +706,18 @@ export async function deleteFile(publicPath: string): Promise<boolean> {
}
}
-// ✅ 편의 함수들 (하위 호환성)
+// 편의 함수들 (하위 호환성)
export const save = {
file: saveFile,
buffer: saveBuffer,
};
-// ✅ DRM 워크플로우 통합 함수
+// DRM 워크플로우 통합 함수 (보안 강화)
export async function saveDRMFile(
originalFile: File,
decryptFunction: (file: File) => Promise<ArrayBuffer>,
- directory: string
+ directory: string,
+ userId?: string
): Promise<SaveFileResult> {
try {
console.log(`🔐 DRM 파일 처리 시작: ${originalFile.name}`);
@@ -261,11 +725,12 @@ export async function saveDRMFile(
// 1. DRM 복호화
const decryptedData = await decryptFunction(originalFile);
- // 2. 복호화된 데이터 저장
+ // 2. 보안 검증과 함께 복호화된 데이터 저장
const result = await saveBuffer({
buffer: decryptedData,
fileName: originalFile.name,
- directory
+ directory,
+ userId,
});
if (result.success) {
@@ -274,10 +739,21 @@ export async function saveDRMFile(
return result;
} catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "DRM 파일 처리 중 오류가 발생했습니다.";
console.error(`❌ DRM 파일 처리 실패: ${originalFile.name}`, error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "DRM 파일 처리 중 오류가 발생했습니다.",
- };
+ FileUploadLogger.logUploadError(originalFile.name, errorMessage, userId);
+ return { success: false, error: errorMessage };
}
+}
+
+// 보안 설정 조회 함수
+export function getSecurityConfig() {
+ return {
+ allowedExtensions: Array.from(SECURITY_CONFIG.ALLOWED_EXTENSIONS),
+ forbiddenExtensions: Array.from(SECURITY_CONFIG.FORBIDDEN_EXTENSIONS),
+ allowedMimeTypes: Array.from(SECURITY_CONFIG.ALLOWED_MIME_TYPES),
+ maxFileSize: SECURITY_CONFIG.MAX_FILE_SIZE,
+ maxFileSizeFormatted: FileUploadLogger['formatFileSize'](SECURITY_CONFIG.MAX_FILE_SIZE),
+ maxFilenameLength: SECURITY_CONFIG.MAX_FILENAME_LENGTH,
+ };
} \ No newline at end of file
diff --git a/lib/network/get-client-ip.ts b/lib/network/get-client-ip.ts
new file mode 100644
index 00000000..566700e9
--- /dev/null
+++ b/lib/network/get-client-ip.ts
@@ -0,0 +1,116 @@
+import { NextRequest } from 'next/server';
+
+export interface GetClientIpOptions {
+ /**
+ * Which index to take from a comma-separated forwarded-for list.
+ * 0 = leftmost (original client; default).
+ * -1 = rightmost (closest hop).
+ */
+ forwardedIndex?: number;
+
+ /**
+ * Custom header priority override. Default uses common reverse-proxy headers.
+ */
+ headerPriority?: readonly string[];
+
+ /**
+ * Value to return when nothing found.
+ * Default: 'unknown'
+ */
+ fallback?: string;
+}
+
+const DEFAULT_HEADER_PRIORITY = [
+ 'x-forwarded-for',
+ 'x-real-ip',
+ 'cf-connecting-ip',
+ 'vercel-forwarded-for',
+] as const;
+
+export function getClientIp(
+ req: NextRequest,
+ opts: GetClientIpOptions = {}
+): string {
+ const {
+ forwardedIndex = 0,
+ headerPriority = DEFAULT_HEADER_PRIORITY,
+ fallback = 'unknown',
+ } = opts;
+
+ for (const h of headerPriority) {
+ const raw = req.headers.get(h);
+ if (!raw) continue;
+
+ // headers like x-forwarded-for can be CSV
+ const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
+ if (parts.length === 0) continue;
+
+ const idx =
+ forwardedIndex >= 0
+ ? forwardedIndex
+ : parts.length + forwardedIndex; // support -1 end indexing
+
+ const sel = parts[idx] ?? parts[0]; // safe fallback to 0
+ const norm = normalizeIp(sel);
+ if (norm) return norm;
+ }
+
+ return fallback;
+}
+
+/**
+ * Normalize IPv4/IPv6/port-suffixed forms to a canonical-ish string.
+ */
+export function normalizeIp(raw: string | null | undefined): string {
+ if (!raw) return '';
+
+ let ip = raw.trim();
+
+ // Strip brackets: [2001:db8::1]
+ if (ip.startsWith('[') && ip.endsWith(']')) {
+ ip = ip.slice(1, -1);
+ }
+
+ // IPv4:port (because some proxies send ip:port)
+ // Heuristic: if '.' present (IPv4-ish) and ':' present, drop port after last colon.
+ if (ip.includes('.') && ip.includes(':')) {
+ ip = ip.slice(0, ip.lastIndexOf(':'));
+ }
+
+ // IPv4-mapped IPv6 ::ffff:203.0.113.5
+ if (ip.startsWith('::ffff:')) {
+ ip = ip.substring(7);
+ }
+
+ return ip;
+}
+
+
+export interface RequestInfo {
+ ip: string;
+ userAgent: string;
+ referer?: string;
+ requestId: string;
+ method: string;
+ url: string;
+ }
+
+ export function getRequestInfo(req: NextRequest, ipOpts?: GetClientIpOptions): RequestInfo {
+ const ip = getClientIp(req, ipOpts);
+ const userAgent = req.headers.get('user-agent')?.trim() || 'unknown';
+
+ const refererRaw = req.headers.get('referer')?.trim();
+ const referer = refererRaw && refererRaw.length > 0 ? refererRaw : undefined;
+
+ const { href, pathname } = req.nextUrl;
+
+ return {
+ ip,
+ userAgent,
+ referer,
+ requestId: (globalThis.crypto?.randomUUID?.() ?? crypto.randomUUID()),
+ method: req.method,
+ url: href || pathname,
+ };
+ }
+ \ No newline at end of file
diff --git a/lib/notification/NotificationContext.tsx b/lib/notification/NotificationContext.tsx
new file mode 100644
index 00000000..b1779264
--- /dev/null
+++ b/lib/notification/NotificationContext.tsx
@@ -0,0 +1,219 @@
+// lib/notification/NotificationContext.tsx
+"use client";
+
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import { useSession } from 'next-auth/react';
+import { toast } from 'sonner'; // 또는 react-hot-toast
+
+interface Notification {
+ id: string;
+ title: string;
+ message: string;
+ type: string;
+ relatedRecordId?: string;
+ relatedRecordType?: string;
+ isRead: boolean;
+ createdAt: string;
+}
+
+interface NotificationContextType {
+ notifications: Notification[];
+ unreadCount: number;
+ markAsRead: (id: string) => Promise<void>;
+ markAllAsRead: () => Promise<void>;
+ refreshNotifications: () => Promise<void>;
+}
+
+const NotificationContext = createContext<NotificationContextType | null>(null);
+
+export function NotificationProvider({ children }: { children: React.ReactNode }) {
+ const { data: session } = useSession();
+ const [notifications, setNotifications] = useState<Notification[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+
+ // 알림 목록 가져오기
+ const refreshNotifications = async () => {
+ if (!session?.user) return;
+
+ try {
+ const response = await fetch('/api/notifications');
+ const data = await response.json();
+ setNotifications(data.notifications);
+ setUnreadCount(data.unreadCount);
+ } catch (error) {
+ console.error('Failed to fetch notifications:', error);
+ }
+ };
+
+ // 읽음 처리
+ const markAsRead = async (id: string) => {
+ try {
+ await fetch(`/api/notifications/${id}/read`, { method: 'PATCH' });
+ setNotifications(prev =>
+ prev.map(n => n.id === id ? { ...n, isRead: true } : n)
+ );
+ setUnreadCount(prev => prev - 1);
+ } catch (error) {
+ console.error('Failed to mark notification as read:', error);
+ }
+ };
+
+ // 모든 알림 읽음 처리
+ const markAllAsRead = async () => {
+ try {
+ await fetch('/api/notifications/read-all', { method: 'PATCH' });
+ setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
+ setUnreadCount(0);
+ } catch (error) {
+ console.error('Failed to mark all notifications as read:', error);
+ }
+ };
+
+ // SSE 연결로 실시간 알림 수신
+ useEffect(() => {
+ if (!session?.user) return;
+
+ // 초기 알림 로드
+ refreshNotifications();
+
+ // SSE 연결
+ const eventSource = new EventSource('/api/notifications/stream');
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+
+ if (data.type === 'heartbeat' || data.type === 'connected') {
+ return; // 하트비트 및 연결 확인 메시지는 무시
+ }
+
+ if (data.type === 'new_notification') {
+ const newNotification = data.data;
+
+ // 새 알림을 기존 목록에 추가
+ setNotifications(prev => [newNotification, ...prev]);
+ setUnreadCount(prev => prev + 1);
+
+ // Toast 알림 표시
+ toast(newNotification.title, {
+ description: newNotification.message,
+ action: {
+ label: "보기",
+ onClick: () => {
+ handleNotificationClick(newNotification);
+ },
+ },
+ });
+ } else if (data.type === 'notification_read') {
+ const readData = data.data;
+
+ // 읽음 상태 업데이트
+ setNotifications(prev =>
+ prev.map(n => n.id === readData.id ? { ...n, isRead: true, readAt: readData.read_at } : n)
+ );
+
+ if (!readData.is_read) {
+ setUnreadCount(prev => Math.max(0, prev - 1));
+ }
+ }
+ } catch (error) {
+ console.error('Failed to parse SSE data:', error);
+ }
+ };
+
+ eventSource.onopen = () => {
+ console.log('SSE connection established');
+ };
+
+ eventSource.onerror = (error) => {
+ console.error('SSE error:', error);
+ // 자동 재연결은 EventSource가 처리
+ };
+
+ return () => {
+ eventSource.close();
+ };
+ }, [session?.user]);
+
+ const handleNotificationClick = (notification: Notification) => {
+ // 알림 클릭 시 관련 페이지로 이동
+ if (notification.relatedRecordId && notification.relatedRecordType) {
+ const url = getNotificationUrl(notification.relatedRecordType, notification.relatedRecordId);
+ window.location.href = url;
+ }
+ markAsRead(notification.id);
+ };
+
+ const getNotificationUrl = (type: string, id: string | null) => {
+ const pathParts = window.location.pathname.split('/');
+ const lng = pathParts[1];
+ const domain = pathParts[2];
+
+ // 도메인별 기본 URL 설정
+ const baseUrls = {
+ 'partners': `/${lng}/partners`,
+ 'procurement': `/${lng}/procurement`,
+ 'sales': `/${lng}/sales`,
+ 'engineering': `/${lng}/engineering`,
+ 'evcp': `/${lng}/evcp`
+ };
+
+ const baseUrl = baseUrls[domain as keyof typeof baseUrls] || `/${lng}/evcp`;
+
+ if (!id) {
+ // ID가 없는 경우 (시스템 공지, 일반 알림 등)
+ switch (type) {
+ case 'evaluation':
+ case 'evaluation_request':
+ return domain === 'partners' ? `${baseUrl}/evaluation` : `${baseUrl}/evaluations`;
+ case 'announcement':
+ case 'system':
+ return `${baseUrl}/announcements`;
+ default:
+ return baseUrl;
+ }
+ }
+
+ // ID가 있는 경우
+ switch (type) {
+ case 'project':
+ return `${baseUrl}/projects/${id}`;
+ case 'task':
+ return `${baseUrl}/tasks/${id}`;
+ case 'order':
+ return `${baseUrl}/orders/${id}`;
+ case 'evaluation_submission':
+ return domain === 'partners'
+ ? `${baseUrl}/evaluation/${id}`
+ : `${baseUrl}/evaluations/submissions/${id}`;
+ case 'document':
+ return `${baseUrl}/documents/${id}`;
+ case 'approval':
+ return `${baseUrl}/approvals/${id}`;
+ default:
+ return baseUrl;
+ }
+ };
+
+ return (
+ <NotificationContext.Provider
+ value={{
+ notifications,
+ unreadCount,
+ markAsRead,
+ markAllAsRead,
+ refreshNotifications
+ }}
+ >
+ {children}
+ </NotificationContext.Provider>
+ );
+}
+
+export function useNotifications() {
+ const context = useContext(NotificationContext);
+ if (!context) {
+ throw new Error('useNotifications must be used within NotificationProvider');
+ }
+ return context;
+} \ No newline at end of file
diff --git a/lib/notification/service.ts b/lib/notification/service.ts
new file mode 100644
index 00000000..f0018113
--- /dev/null
+++ b/lib/notification/service.ts
@@ -0,0 +1,342 @@
+// lib/notification/service.ts
+import db from '@/db/db';
+import { notifications, type NewNotification, type Notification } from '@/db/schema';
+import { eq, and, desc, count, gt } from 'drizzle-orm';
+
+// 알림 생성
+export async function createNotification(data: Omit<NewNotification, 'id' | 'createdAt'>) {
+ const [notification] = await db
+ .insert(notifications)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ })
+ .returning();
+
+ return notification;
+}
+
+// 여러 알림 한번에 생성 (벌크 생성)
+export async function createNotifications(data: Omit<NewNotification, 'id' | 'createdAt'>[]) {
+ if (data.length === 0) return [];
+
+ const notificationsData = data.map(item => ({
+ ...item,
+ createdAt: new Date(),
+ }));
+
+ const createdNotifications = await db
+ .insert(notifications)
+ .values(notificationsData)
+ .returning();
+
+ return createdNotifications;
+}
+
+// 사용자의 알림 목록 조회 (페이지네이션 포함)
+export async function getUserNotifications(
+ userId: string,
+ options: {
+ limit?: number;
+ offset?: number;
+ unreadOnly?: boolean;
+ } = {}
+) {
+ const { limit = 20, offset = 0, unreadOnly = false } = options;
+
+ const whereConditions = [eq(notifications.userId, userId)];
+
+ if (unreadOnly) {
+ whereConditions.push(eq(notifications.isRead, false));
+ }
+
+ const userNotifications = await db
+ .select()
+ .from(notifications)
+ .where(and(...whereConditions))
+ .orderBy(desc(notifications.createdAt))
+ .limit(limit)
+ .offset(offset);
+
+ return userNotifications;
+}
+
+// 읽지 않은 알림 개수 조회
+export async function getUnreadNotificationCount(userId: string) {
+ const [result] = await db
+ .select({ count: count() })
+ .from(notifications)
+ .where(
+ and(
+ eq(notifications.userId, userId),
+ eq(notifications.isRead, false)
+ )
+ );
+
+ return result.count;
+}
+
+// 알림 읽음 처리
+export async function markNotificationAsRead(notificationId: string, userId: string) {
+ const [updatedNotification] = await db
+ .update(notifications)
+ .set({
+ isRead: true,
+ readAt: new Date()
+ })
+ .where(
+ and(
+ eq(notifications.id, notificationId),
+ eq(notifications.userId, userId)
+ )
+ )
+ .returning();
+
+ return updatedNotification;
+}
+
+// 모든 알림 읽음 처리
+export async function markAllNotificationsAsRead(userId: string) {
+ const updatedNotifications = await db
+ .update(notifications)
+ .set({
+ isRead: true,
+ readAt: new Date()
+ })
+ .where(
+ and(
+ eq(notifications.userId, userId),
+ eq(notifications.isRead, false)
+ )
+ )
+ .returning();
+
+ return updatedNotifications;
+}
+
+// 특정 알림 삭제
+export async function deleteNotification(notificationId: string, userId: string) {
+ const [deletedNotification] = await db
+ .delete(notifications)
+ .where(
+ and(
+ eq(notifications.id, notificationId),
+ eq(notifications.userId, userId)
+ )
+ )
+ .returning();
+
+ return deletedNotification;
+}
+
+// 최근 알림 조회 (실시간 업데이트용)
+export async function getLatestNotifications(userId: string, since?: Date) {
+ const whereConditions = [eq(notifications.userId, userId)];
+
+ if (since) {
+ whereConditions.push(gt(notifications.createdAt, since));
+ }
+
+ const latestNotifications = await db
+ .select()
+ .from(notifications)
+ .where(and(...whereConditions))
+ .orderBy(desc(notifications.createdAt))
+ .limit(10);
+
+ return latestNotifications;
+}
+
+// =========================
+// 알림 템플릿 및 헬퍼 함수들
+// =========================
+
+export const NotificationTemplates = {
+ // 프로젝트 관련
+ projectAssigned: (projectName: string, projectId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '새 프로젝트가 할당되었습니다',
+ message: `"${projectName}" 프로젝트가 회원님에게 할당되었습니다.`,
+ type: 'assignment',
+ relatedRecordId: projectId,
+ relatedRecordType: 'project',
+ isRead: false,
+ }),
+
+ // 작업 관련
+ taskAssigned: (taskName: string, taskId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '새 작업이 할당되었습니다',
+ message: `"${taskName}" 작업이 회원님에게 할당되었습니다.`,
+ type: 'assignment',
+ relatedRecordId: taskId,
+ relatedRecordType: 'task',
+ isRead: false,
+ }),
+
+ // 주문 관련
+ orderStatusChanged: (orderNumber: string, status: string, orderId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '주문 상태가 변경되었습니다',
+ message: `주문 ${orderNumber}의 상태가 "${status}"로 변경되었습니다.`,
+ type: 'status_change',
+ relatedRecordId: orderId,
+ relatedRecordType: 'order',
+ isRead: false,
+ }),
+
+ // 평가 관련
+ evaluationDocumentRequested: (evaluationYear: number, evaluationRound: string, dueDate?: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '협력업체 평가 자료 제출 요청',
+ message: `${evaluationYear}년 ${evaluationRound} 협력업체 평가 자료 제출이 요청되었습니다.${dueDate ? ` 마감일: ${dueDate}` : ''}`,
+ type: 'evaluation_request',
+ relatedRecordId: null,
+ relatedRecordType: 'evaluation',
+ isRead: false,
+ }),
+
+ evaluationRequestCompleted: (vendorCount: number, evaluationYear: number, evaluationRound: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '평가 자료 요청이 완료되었습니다',
+ message: `${vendorCount}개 협력업체에게 ${evaluationYear}년 ${evaluationRound} 평가 자료 요청이 완료되었습니다.`,
+ type: 'evaluation_admin',
+ relatedRecordId: null,
+ relatedRecordType: 'evaluation',
+ isRead: false,
+ }),
+
+ evaluationSubmitted: (vendorName: string, evaluationYear: number, evaluationRound: string, submissionId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '협력업체 평가가 제출되었습니다',
+ message: `${vendorName}에서 ${evaluationYear}년 ${evaluationRound} 평가를 제출했습니다.`,
+ type: 'evaluation_submission',
+ relatedRecordId: submissionId,
+ relatedRecordType: 'evaluation_submission',
+ isRead: false,
+ }),
+
+ // 승인 관련
+ approvalRequired: (documentName: string, documentId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '승인이 필요합니다',
+ message: `"${documentName}" 문서의 승인이 필요합니다.`,
+ type: 'approval',
+ relatedRecordId: documentId,
+ relatedRecordType: 'document',
+ isRead: false,
+ }),
+
+ // 마감일 관련
+ deadlineReminder: (taskName: string, daysLeft: number, taskId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title: '마감일 알림',
+ message: `"${taskName}" 작업의 마감일이 ${daysLeft}일 남았습니다.`,
+ type: 'deadline',
+ relatedRecordId: taskId,
+ relatedRecordType: 'task',
+ isRead: false,
+ }),
+
+ // 시스템 공지
+ systemAnnouncement: (title: string, message: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({
+ title,
+ message,
+ type: 'announcement',
+ relatedRecordId: null,
+ relatedRecordType: 'system',
+ isRead: false,
+ }),
+};
+
+// =========================
+// 비즈니스 로직별 편의 함수들
+// =========================
+
+// 프로젝트 할당 알림
+export async function notifyProjectAssignment(userId: string, projectName: string, projectId: string) {
+ return await createNotification({
+ userId,
+ ...NotificationTemplates.projectAssigned(projectName, projectId),
+ });
+}
+
+// 작업 할당 알림
+export async function notifyTaskAssignment(userId: string, taskName: string, taskId: string) {
+ return await createNotification({
+ userId,
+ ...NotificationTemplates.taskAssigned(taskName, taskId),
+ });
+}
+
+// 주문 상태 변경 알림
+export async function notifyOrderStatusChange(userId: string, orderNumber: string, status: string, orderId: string) {
+ return await createNotification({
+ userId,
+ ...NotificationTemplates.orderStatusChanged(orderNumber, status, orderId),
+ });
+}
+
+// 평가 자료 요청 알림 (벤더에게)
+export async function notifyEvaluationDocumentRequest(
+ userIds: string[],
+ evaluationYear: number,
+ evaluationRound: string,
+ dueDate?: string
+) {
+ const template = NotificationTemplates.evaluationDocumentRequested(evaluationYear, evaluationRound, dueDate);
+
+ const notificationsData = userIds.map(userId => ({
+ userId,
+ ...template,
+ }));
+
+ return await createNotifications(notificationsData);
+}
+
+// 평가 요청 완료 알림 (관리자에게)
+export async function notifyEvaluationRequestCompleted(
+ adminUserIds: string[],
+ vendorCount: number,
+ evaluationYear: number,
+ evaluationRound: string
+) {
+ const template = NotificationTemplates.evaluationRequestCompleted(vendorCount, evaluationYear, evaluationRound);
+
+ const notificationsData = adminUserIds.map(userId => ({
+ userId,
+ ...template,
+ }));
+
+ return await createNotifications(notificationsData);
+}
+
+// 평가 제출 알림 (평가 담당자에게)
+export async function notifyEvaluationSubmission(
+ evaluatorUserIds: string[],
+ vendorName: string,
+ evaluationYear: number,
+ evaluationRound: string,
+ submissionId: string
+) {
+ const template = NotificationTemplates.evaluationSubmitted(vendorName, evaluationYear, evaluationRound, submissionId);
+
+ const notificationsData = evaluatorUserIds.map(userId => ({
+ userId,
+ ...template,
+ }));
+
+ return await createNotifications(notificationsData);
+}
+
+// 승인 요청 알림
+export async function notifyApprovalRequired(userId: string, documentName: string, documentId: string) {
+ return await createNotification({
+ userId,
+ ...NotificationTemplates.approvalRequired(documentName, documentId),
+ });
+}
+
+// 시스템 공지 (전체 사용자)
+export async function broadcastSystemAnnouncement(userIds: string[], title: string, message: string) {
+ const template = NotificationTemplates.systemAnnouncement(title, message);
+
+ const notificationsData = userIds.map(userId => ({
+ userId,
+ ...template,
+ }));
+
+ return await createNotifications(notificationsData);
+} \ No newline at end of file
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
new file mode 100644
index 00000000..db90dcff
--- /dev/null
+++ b/lib/rate-limit.ts
@@ -0,0 +1,63 @@
+// lib/rate-limit.ts
+import { NextRequest } from 'next/server';
+import { getClientIp } from './network/get-client-ip';
+
+interface RateLimitResult {
+ success: boolean;
+ remaining?: number;
+}
+
+// 메모리 기반 간단한 rate limiter (프로덕션에서는 Redis 사용 권장)
+const requestCounts = new Map<string, { count: number; resetTime: number }>();
+
+
+
+export default async function rateLimit(
+ request: NextRequest,
+ maxRequests: number = 100,
+ windowMs: number = 60 * 60 * 1000 // 1시간
+): Promise<RateLimitResult> {
+ // 클라이언트 IP 가져오기
+ const clientIP = getClientIp(request);
+
+
+ const now = Date.now();
+ const key = `rate_limit:${clientIP}`;
+
+ // 기존 요청 정보 가져오기
+ let requestInfo = requestCounts.get(key);
+
+ // 윈도우가 리셋된 경우 또는 첫 요청인 경우
+ if (!requestInfo || now > requestInfo.resetTime) {
+ requestInfo = {
+ count: 1,
+ resetTime: now + windowMs
+ };
+ requestCounts.set(key, requestInfo);
+ return { success: true, remaining: maxRequests - 1 };
+ }
+
+ // 요청 한도 초과 확인
+ if (requestInfo.count >= maxRequests) {
+ return { success: false, remaining: 0 };
+ }
+
+ // 요청 카운트 증가
+ requestInfo.count++;
+ requestCounts.set(key, requestInfo);
+
+ return {
+ success: true,
+ remaining: maxRequests - requestInfo.count
+ };
+}
+
+// 주기적으로 만료된 항목 정리 (메모리 누수 방지)
+setInterval(() => {
+ const now = Date.now();
+ for (const [key, value] of requestCounts.entries()) {
+ if (now > value.resetTime) {
+ requestCounts.delete(key);
+ }
+ }
+}, 60 * 60 * 1000); // 1시간마다 정리 \ No newline at end of file
diff --git a/lib/realtime/NotificationManager.ts b/lib/realtime/NotificationManager.ts
new file mode 100644
index 00000000..88eed387
--- /dev/null
+++ b/lib/realtime/NotificationManager.ts
@@ -0,0 +1,223 @@
+// lib/realtime/NotificationManager.ts
+import { Client } from 'pg';
+import { EventEmitter } from 'events';
+
+interface NotificationPayload {
+ id: string;
+ user_id: string;
+ title: string;
+ message: string;
+ type: string;
+ related_record_id?: string;
+ related_record_type?: string;
+ is_read: boolean;
+ created_at: string;
+}
+
+interface NotificationReadPayload {
+ id: string;
+ user_id: string;
+ is_read: boolean;
+ read_at?: string;
+}
+
+class NotificationManager extends EventEmitter {
+ private client: Client | null = null;
+ private isConnected = false;
+ private reconnectAttempts = 0;
+ private maxReconnectAttempts = 5;
+ private reconnectDelay = 1000;
+ private reconnectTimeout: NodeJS.Timeout | null = null;
+
+ constructor() {
+ super();
+ this.setMaxListeners(100); // SSE 연결이 많을 수 있으므로 제한 증가
+ this.connect();
+ }
+
+ private async connect() {
+ try {
+ // 기존 연결이 있으면 정리
+ if (this.client) {
+ try {
+ await this.client.end();
+ } catch (error) {
+ console.warn('Error closing existing connection:', error);
+ }
+ }
+
+ this.client = new Client({
+ connectionString: process.env.DATABASE_URL,
+ application_name: 'notification_listener',
+ // 연결 유지 설정
+ keepAlive: true,
+ keepAliveInitialDelayMillis: 10000,
+ });
+
+ await this.client.connect();
+
+ // LISTEN 채널 구독
+ await this.client.query('LISTEN new_notification');
+ await this.client.query('LISTEN notification_read');
+
+ console.log('NotificationManager: PostgreSQL LISTEN connected');
+
+ // 알림 수신 처리
+ this.client.on('notification', (msg) => {
+ try {
+ if (msg.channel === 'new_notification') {
+ const payload: NotificationPayload = JSON.parse(msg.payload || '{}');
+ console.log('New notification received:', payload.id, 'for user:', payload.user_id);
+ this.emit('newNotification', payload);
+ } else if (msg.channel === 'notification_read') {
+ const payload: NotificationReadPayload = JSON.parse(msg.payload || '{}');
+ console.log('Notification read:', payload.id, 'by user:', payload.user_id);
+ this.emit('notificationRead', payload);
+ }
+ } catch (error) {
+ console.error('Error parsing notification payload:', error);
+ }
+ });
+
+ // 연결 오류 처리
+ this.client.on('error', (error) => {
+ console.error('PostgreSQL LISTEN error:', error);
+ this.isConnected = false;
+ this.scheduleReconnect();
+ });
+
+ // 연결 종료 처리
+ this.client.on('end', () => {
+ console.log('PostgreSQL LISTEN connection ended');
+ this.isConnected = false;
+ this.scheduleReconnect();
+ });
+
+ this.isConnected = true;
+ this.reconnectAttempts = 0;
+
+ // 연결 성공 이벤트 발송
+ this.emit('connected');
+
+ } catch (error) {
+ console.error('Failed to connect NotificationManager:', error);
+ this.isConnected = false;
+ this.scheduleReconnect();
+ }
+ }
+
+ private scheduleReconnect() {
+ // 이미 재연결이 예약되어 있으면 무시
+ if (this.reconnectTimeout) {
+ return;
+ }
+
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.error('Max reconnection attempts reached for NotificationManager');
+ this.emit('maxReconnectAttemptsReached');
+ return;
+ }
+
+ this.reconnectAttempts++;
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
+
+ console.log(`Scheduling NotificationManager reconnection in ${delay}ms (attempt ${this.reconnectAttempts})`);
+
+ this.reconnectTimeout = setTimeout(() => {
+ this.reconnectTimeout = null;
+ this.connect();
+ }, delay);
+ }
+
+ public async disconnect() {
+ if (this.reconnectTimeout) {
+ clearTimeout(this.reconnectTimeout);
+ this.reconnectTimeout = null;
+ }
+
+ if (this.client) {
+ try {
+ await this.client.end();
+ } catch (error) {
+ console.warn('Error disconnecting NotificationManager:', error);
+ }
+ this.client = null;
+ }
+
+ this.isConnected = false;
+ this.emit('disconnected');
+ }
+
+ public getConnectionStatus(): boolean {
+ return this.isConnected && this.client !== null;
+ }
+
+ public getReconnectAttempts(): number {
+ return this.reconnectAttempts;
+ }
+
+ // 강제 재연결 (관리자 API용)
+ public async forceReconnect() {
+ console.log('Forcing NotificationManager reconnection...');
+ this.reconnectAttempts = 0;
+ if (this.reconnectTimeout) {
+ clearTimeout(this.reconnectTimeout);
+ this.reconnectTimeout = null;
+ }
+ await this.disconnect();
+ await this.connect();
+ }
+
+ // 연결 상태 체크 (헬스체크용)
+ public async healthCheck(): Promise<{ status: string; details: any }> {
+ try {
+ if (!this.client || !this.isConnected) {
+ return {
+ status: 'unhealthy',
+ details: {
+ connected: false,
+ reconnectAttempts: this.reconnectAttempts,
+ maxReconnectAttempts: this.maxReconnectAttempts
+ }
+ };
+ }
+
+ // 간단한 쿼리로 연결 상태 확인
+ await this.client.query('SELECT 1');
+
+ return {
+ status: 'healthy',
+ details: {
+ connected: true,
+ reconnectAttempts: this.reconnectAttempts,
+ uptime: process.uptime()
+ }
+ };
+ } catch (error) {
+ return {
+ status: 'unhealthy',
+ details: {
+ connected: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ reconnectAttempts: this.reconnectAttempts
+ }
+ };
+ }
+ }
+}
+
+// 싱글톤 인스턴스
+const notificationManager = new NotificationManager();
+
+// 프로세스 종료 시 정리
+process.on('SIGINT', async () => {
+ console.log('Shutting down NotificationManager...');
+ await notificationManager.disconnect();
+});
+
+process.on('SIGTERM', async () => {
+ console.log('Shutting down NotificationManager...');
+ await notificationManager.disconnect();
+});
+
+export default notificationManager; \ No newline at end of file
diff --git a/lib/realtime/RealtimeNotificationService.ts b/lib/realtime/RealtimeNotificationService.ts
new file mode 100644
index 00000000..4bc728ee
--- /dev/null
+++ b/lib/realtime/RealtimeNotificationService.ts
@@ -0,0 +1,362 @@
+// lib/realtime/RealtimeNotificationService.ts
+import notificationManager from './NotificationManager';
+
+interface ConnectedClient {
+ userId: string;
+ controller: ReadableStreamDefaultController;
+ lastHeartbeat: number;
+ connectionId: string;
+ userAgent?: string;
+ ipAddress?: string;
+}
+
+class RealtimeNotificationService {
+ private connectedClients = new Map<string, ConnectedClient[]>();
+ private heartbeatInterval: NodeJS.Timeout | null = null;
+ private clientTimeout = 5 * 60 * 1000; // 5분
+ private heartbeatFrequency = 30 * 1000; // 30초
+
+ constructor() {
+ this.setupEventListeners();
+ this.startHeartbeat();
+ this.startConnectionCleanup();
+ }
+
+ private setupEventListeners() {
+ // PostgreSQL NOTIFY 이벤트 수신 시 클라이언트들에게 브로드캐스트
+ notificationManager.on('newNotification', (payload: any) => {
+ console.log('Broadcasting new notification to user:', payload.user_id);
+ this.broadcastToUser(payload.user_id, {
+ type: 'new_notification',
+ data: payload
+ });
+ });
+
+ notificationManager.on('notificationRead', (payload: any) => {
+ console.log('Broadcasting notification read to user:', payload.user_id);
+ this.broadcastToUser(payload.user_id, {
+ type: 'notification_read',
+ data: payload
+ });
+ });
+
+ // NotificationManager 연결 상태 변화 시 클라이언트들에게 알림
+ notificationManager.on('connected', () => {
+ this.broadcastToAllUsers({
+ type: 'system_status',
+ data: { status: 'connected', message: 'Real-time notifications are now active' }
+ });
+ });
+
+ notificationManager.on('disconnected', () => {
+ this.broadcastToAllUsers({
+ type: 'system_status',
+ data: { status: 'disconnected', message: 'Real-time notifications temporarily unavailable' }
+ });
+ });
+ }
+
+ // 클라이언트 연결 등록
+ public addClient(
+ userId: string,
+ controller: ReadableStreamDefaultController,
+ metadata?: { userAgent?: string; ipAddress?: string }
+ ): string {
+ const connectionId = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+
+ if (!this.connectedClients.has(userId)) {
+ this.connectedClients.set(userId, []);
+ }
+
+ const clients = this.connectedClients.get(userId)!;
+
+ // 동일 사용자의 최대 연결 수 제한
+ const maxConnectionsPerUser = parseInt(process.env.MAX_CONNECTIONS_PER_USER || '5');
+ if (clients.length >= maxConnectionsPerUser) {
+ console.warn(`Max connections (${maxConnectionsPerUser}) reached for user ${userId}`);
+ // 가장 오래된 연결 제거
+ const oldestClient = clients.shift();
+ if (oldestClient) {
+ try {
+ oldestClient.controller.close();
+ } catch (error) {
+ console.warn('Error closing oldest connection:', error);
+ }
+ }
+ }
+
+ clients.push({
+ userId,
+ controller,
+ lastHeartbeat: Date.now(),
+ connectionId,
+ userAgent: metadata?.userAgent,
+ ipAddress: metadata?.ipAddress
+ });
+
+ console.log(`Client ${connectionId} connected for user ${userId} (total: ${clients.length})`);
+
+ // 연결 확인 메시지 전송
+ this.sendToClient(controller, {
+ type: 'connected',
+ data: {
+ connectionId,
+ serverTime: new Date().toISOString(),
+ dbStatus: notificationManager.getConnectionStatus()
+ }
+ });
+
+ return connectionId;
+ }
+
+ // 클라이언트 연결 해제
+ public removeClient(userId: string, controller: ReadableStreamDefaultController): void {
+ const clients = this.connectedClients.get(userId);
+ if (!clients) return;
+
+ const filteredClients = clients.filter(client => {
+ if (client.controller === controller) {
+ console.log(`Client ${client.connectionId} disconnected for user ${userId}`);
+ return false;
+ }
+ return true;
+ });
+
+ if (filteredClients.length === 0) {
+ this.connectedClients.delete(userId);
+ console.log(`All clients disconnected for user ${userId}`);
+ } else {
+ this.connectedClients.set(userId, filteredClients);
+ }
+ }
+
+ // 특정 사용자에게 메시지 브로드캐스트
+ private broadcastToUser(userId: string, message: any): void {
+ const clients = this.connectedClients.get(userId);
+ if (!clients || clients.length === 0) {
+ console.log(`No connected clients for user ${userId}`);
+ return;
+ }
+
+ const activeClients: ConnectedClient[] = [];
+ let sentCount = 0;
+
+ for (const client of clients) {
+ if (this.sendToClient(client.controller, message)) {
+ client.lastHeartbeat = Date.now();
+ activeClients.push(client);
+ sentCount++;
+ }
+ }
+
+ // 활성 클라이언트만 유지
+ if (activeClients.length === 0) {
+ this.connectedClients.delete(userId);
+ } else {
+ this.connectedClients.set(userId, activeClients);
+ }
+
+ console.log(`Message sent to ${sentCount}/${clients.length} clients for user ${userId}`);
+ }
+
+ // 모든 사용자에게 브로드캐스트 (시스템 메시지용)
+ private broadcastToAllUsers(message: any): void {
+ let totalSent = 0;
+ let totalClients = 0;
+
+ for (const [userId, clients] of this.connectedClients.entries()) {
+ totalClients += clients.length;
+ const activeClients: ConnectedClient[] = [];
+
+ for (const client of clients) {
+ if (this.sendToClient(client.controller, message)) {
+ client.lastHeartbeat = Date.now();
+ activeClients.push(client);
+ totalSent++;
+ }
+ }
+
+ if (activeClients.length === 0) {
+ this.connectedClients.delete(userId);
+ } else {
+ this.connectedClients.set(userId, activeClients);
+ }
+ }
+
+ console.log(`System message sent to ${totalSent}/${totalClients} clients`);
+ }
+
+ // 개별 클라이언트에게 메시지 전송
+ private sendToClient(controller: ReadableStreamDefaultController, message: any): boolean {
+ try {
+ const encoder = new TextEncoder();
+ const data = `data: ${JSON.stringify(message)}\n\n`;
+ controller.enqueue(encoder.encode(data));
+ return true;
+ } catch (error) {
+ console.warn('Failed to send message to client:', error);
+ return false;
+ }
+ }
+
+ // 하트비트 전송 (연결 유지)
+ private startHeartbeat(): void {
+ this.heartbeatInterval = setInterval(() => {
+ const heartbeatMessage = {
+ type: 'heartbeat',
+ data: {
+ timestamp: new Date().toISOString(),
+ serverStatus: 'healthy',
+ dbStatus: notificationManager.getConnectionStatus()
+ }
+ };
+
+ this.broadcastToAllUsers(heartbeatMessage);
+ }, this.heartbeatFrequency);
+
+ console.log(`Heartbeat started with ${this.heartbeatFrequency}ms interval`);
+ }
+
+ // 비활성 연결 정리
+ private startConnectionCleanup(): void {
+ setInterval(() => {
+ const now = Date.now();
+ let cleanedConnections = 0;
+
+ for (const [userId, clients] of this.connectedClients.entries()) {
+ const activeClients = clients.filter(client => {
+ const isActive = (now - client.lastHeartbeat) < this.clientTimeout;
+ if (!isActive) {
+ try {
+ client.controller.close();
+ } catch (error) {
+ // 이미 닫힌 연결일 수 있음
+ }
+ cleanedConnections++;
+ }
+ return isActive;
+ });
+
+ if (activeClients.length === 0) {
+ this.connectedClients.delete(userId);
+ } else {
+ this.connectedClients.set(userId, activeClients);
+ }
+ }
+
+ if (cleanedConnections > 0) {
+ console.log(`Cleaned up ${cleanedConnections} inactive connections`);
+ }
+ }, 60000); // 1분마다 정리
+ }
+
+ // 연결된 클라이언트 수 조회
+ public getConnectedClientCount(): number {
+ let count = 0;
+ for (const clients of this.connectedClients.values()) {
+ count += clients.length;
+ }
+ return count;
+ }
+
+ // 특정 사용자의 연결된 클라이언트 수 조회
+ public getUserClientCount(userId: string): number {
+ return this.connectedClients.get(userId)?.length || 0;
+ }
+
+ // 연결된 사용자 수 조회
+ public getConnectedUserCount(): number {
+ return this.connectedClients.size;
+ }
+
+ // 상세 연결 정보 조회 (관리자용)
+ public getDetailedConnectionInfo() {
+ const info = {
+ totalClients: this.getConnectedClientCount(),
+ totalUsers: this.getConnectedUserCount(),
+ dbConnectionStatus: notificationManager.getConnectionStatus(),
+ connections: {} as any
+ };
+
+ for (const [userId, clients] of this.connectedClients.entries()) {
+ info.connections[userId] = clients.map(client => ({
+ connectionId: client.connectionId,
+ lastHeartbeat: new Date(client.lastHeartbeat).toISOString(),
+ userAgent: client.userAgent,
+ ipAddress: client.ipAddress,
+ connectedFor: Date.now() - client.lastHeartbeat
+ }));
+ }
+
+ return info;
+ }
+
+ // 특정 사용자의 모든 연결 해제 (관리자용)
+ public disconnectUser(userId: string): number {
+ const clients = this.connectedClients.get(userId);
+ if (!clients) return 0;
+
+ const count = clients.length;
+
+ for (const client of clients) {
+ try {
+ this.sendToClient(client.controller, {
+ type: 'force_disconnect',
+ data: { reason: 'Administrative action' }
+ });
+ client.controller.close();
+ } catch (error) {
+ console.warn(`Error disconnecting client ${client.connectionId}:`, error);
+ }
+ }
+
+ this.connectedClients.delete(userId);
+ console.log(`Forcibly disconnected ${count} clients for user ${userId}`);
+
+ return count;
+ }
+
+ // 서비스 종료
+ public shutdown(): void {
+ console.log('Shutting down RealtimeNotificationService...');
+
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ this.heartbeatInterval = null;
+ }
+
+ // 모든 연결에게 종료 메시지 전송 후 연결 해제
+ const shutdownMessage = {
+ type: 'server_shutdown',
+ data: { message: 'Server is shutting down. Please reconnect in a moment.' }
+ };
+
+ for (const clients of this.connectedClients.values()) {
+ for (const client of clients) {
+ try {
+ this.sendToClient(client.controller, shutdownMessage);
+ client.controller.close();
+ } catch (error) {
+ // 이미 종료된 연결 무시
+ }
+ }
+ }
+
+ this.connectedClients.clear();
+ console.log('RealtimeNotificationService shutdown complete');
+ }
+}
+
+// 싱글톤 인스턴스
+const realtimeNotificationService = new RealtimeNotificationService();
+
+// 프로세스 종료 시 정리
+process.on('SIGINT', () => {
+ realtimeNotificationService.shutdown();
+});
+
+process.on('SIGTERM', () => {
+ realtimeNotificationService.shutdown();
+});
+
+export default realtimeNotificationService; \ No newline at end of file
diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts
index f307d54a..d37b1890 100644
--- a/lib/sedp/get-form-tags.ts
+++ b/lib/sedp/get-form-tags.ts
@@ -13,7 +13,7 @@ import {
} from "@/db/schema";
import { eq, and, like, inArray } from "drizzle-orm";
import { getSEDPToken } from "./sedp-token";
-import { getFormMappingsByTagType } from "../tags/form-mapping-service";
+import { getFormMappingsByTagTypebyProeject } from "../tags/form-mapping-service";
interface Attribute {
@@ -153,17 +153,15 @@ export async function importTagsFromSEDP(
}
// 태그 타입에 따른 폼 정보 가져오기
- const allFormMappings = await getFormMappingsByTagType(
- tagTypeDescription,
+ const allFormMappings = await getFormMappingsByTagTypebyProeject(
projectId,
- tagClassLabel
);
// ep가 "IMEP"인 것만 필터링
- const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [];
+ // const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [];
// 현재 formCode와 일치하는 매핑 찾기
- const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode);
+ const targetFormMapping = allFormMappings.find(mapping => mapping.formCode === formCode);
if (targetFormMapping) {
console.log(`[IMPORT TAGS] Found IMEP form mapping for ${formCode}, creating form...`);
@@ -246,17 +244,15 @@ export async function importTagsFromSEDP(
}
// form mapping 정보 가져오기
- const allFormMappings = await getFormMappingsByTagType(
- tagTypeDescription,
+ const allFormMappings = await getFormMappingsByTagTypebyProeject(
projectId,
- tagClassLabel
);
// ep가 "IMEP"인 것만 필터링
- const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [];
+ // const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [];
// 현재 formCode와 일치하는 매핑 찾기
- const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode);
+ const targetFormMapping = allFormMappings.find(mapping => mapping.formCode === formCode);
if (targetFormMapping) {
targetImValue = targetFormMapping.ep === "IMEP";
diff --git a/lib/tags/form-mapping-service.ts b/lib/tags/form-mapping-service.ts
index 3e86e9d9..6de0e244 100644
--- a/lib/tags/form-mapping-service.ts
+++ b/lib/tags/form-mapping-service.ts
@@ -70,4 +70,32 @@ export async function getFormMappingsByTagType(
// 3) 아무것도 없으면 빈 배열
console.log(`No mappings found at all for tagType="${tagType}"`);
return [];
+}
+
+
+export async function getFormMappingsByTagTypebyProeject(
+
+ projectId: number,
+): Promise<FormMapping[]> {
+
+ const specificRows = await db
+ .select({
+ formCode: tagTypeClassFormMappings.formCode,
+ formName: tagTypeClassFormMappings.formName,
+ ep: tagTypeClassFormMappings.ep,
+ remark: tagTypeClassFormMappings.remark
+ })
+ .from(tagTypeClassFormMappings)
+ .where(and(
+ eq(tagTypeClassFormMappings.projectId, projectId),
+ ))
+
+ if (specificRows.length > 0) {
+ console.log("Found specific mapping rows:", specificRows.length);
+ return specificRows;
+ }
+
+
+
+ return [];
} \ No newline at end of file