diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/file-download-log/service.ts | 353 | ||||
| -rw-r--r-- | lib/file-download-log/validation.ts | 37 | ||||
| -rw-r--r-- | lib/file-download.ts | 471 | ||||
| -rw-r--r-- | lib/file-stroage.ts | 588 | ||||
| -rw-r--r-- | lib/network/get-client-ip.ts | 116 | ||||
| -rw-r--r-- | lib/notification/NotificationContext.tsx | 219 | ||||
| -rw-r--r-- | lib/notification/service.ts | 342 | ||||
| -rw-r--r-- | lib/rate-limit.ts | 63 | ||||
| -rw-r--r-- | lib/realtime/NotificationManager.ts | 223 | ||||
| -rw-r--r-- | lib/realtime/RealtimeNotificationService.ts | 362 | ||||
| -rw-r--r-- | lib/sedp/get-form-tags.ts | 18 | ||||
| -rw-r--r-- | lib/tags/form-mapping-service.ts | 28 |
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, '&') // & → & + .replace(/</g, '<') // < → < + .replace(/>/g, '>') // > → > + .replace(/"/g, '"') // " → " + .replace(/'/g, ''') // ' → ' + .replace(/\//g, '/') // / → / + .replace(/\\/g, '\'); // \ → \ +} + +// 파일명에서 위험한 문자 제거 (저장용) +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 |
