diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-18 07:52:02 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-18 07:52:02 +0000 |
| commit | 48a2255bfc45ffcfb0b39ffefdd57cbacf8b36df (patch) | |
| tree | 0c88b7c126138233875e8d372a4e999e49c38a62 /app | |
| parent | 2ef02e27dbe639876fa3b90c30307dda183545ec (diff) | |
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/partners/(partners)/system/layout.tsx | 6 | ||||
| -rw-r--r-- | app/[lng]/qna/layout.tsx | 18 | ||||
| -rw-r--r-- | app/[lng]/qna/page.tsx | 8 | ||||
| -rw-r--r-- | app/api/client-logs/route.ts | 259 | ||||
| -rw-r--r-- | app/api/notifications/[id]/deleate/route.ts | 34 | ||||
| -rw-r--r-- | app/api/notifications/[id]/read/route.ts | 34 | ||||
| -rw-r--r-- | app/api/notifications/read-all/route.ts | 26 | ||||
| -rw-r--r-- | app/api/notifications/route.ts | 37 | ||||
| -rw-r--r-- | app/api/notifications/stats/route.ts | 28 | ||||
| -rw-r--r-- | app/api/notifications/stream/route.ts | 54 | ||||
| -rw-r--r-- | app/api/rfq-attachments/download/route.ts | 371 | ||||
| -rw-r--r-- | app/api/rfq-download/route.ts | 262 | ||||
| -rw-r--r-- | app/api/tbe-download/route.ts | 432 | ||||
| -rw-r--r-- | app/api/tech-sales-rfq-download/route.ts | 436 |
14 files changed, 1708 insertions, 297 deletions
diff --git a/app/[lng]/partners/(partners)/system/layout.tsx b/app/[lng]/partners/(partners)/system/layout.tsx index 54330176..66f0f9cc 100644 --- a/app/[lng]/partners/(partners)/system/layout.tsx +++ b/app/[lng]/partners/(partners)/system/layout.tsx @@ -29,15 +29,15 @@ export default async function SettingsLayout({ { title: "사용자", - href: `/${lng}/evcp/system`, + href: `/${lng}/partners/system`, }, { title: "Roles", - href: `/${lng}/evcp/system/roles`, + href: `/${lng}/partners/system/roles`, }, { title: "권한 통제", - href: `/${lng}/evcp/system/permissions`, + href: `/${lng}/partners/system/permissions`, }, ] diff --git a/app/[lng]/qna/layout.tsx b/app/[lng]/qna/layout.tsx deleted file mode 100644 index 87651e92..00000000 --- a/app/[lng]/qna/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ReactNode } from 'react'; - - -export default function EvcpLayout({ children }: { children: ReactNode }) { - return ( - <div className="relative flex min-h-svh flex-col bg-background"> - <main className="flex flex-1 flex-col"> - <div className='container-wrapper'> - <div className="container flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10"> - {children} - </div> - - </div> - - </main> - </div> - ); -}
\ No newline at end of file diff --git a/app/[lng]/qna/page.tsx b/app/[lng]/qna/page.tsx deleted file mode 100644 index 10280464..00000000 --- a/app/[lng]/qna/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ - -export default function Pages() { - return ( - <> - qna - </> - ) - }
\ No newline at end of file diff --git a/app/api/client-logs/route.ts b/app/api/client-logs/route.ts new file mode 100644 index 00000000..42e7db63 --- /dev/null +++ b/app/api/client-logs/route.ts @@ -0,0 +1,259 @@ +// app/api/client-logs/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { createFileDownloadLog } from '@/lib/file-download-log/service'; +import rateLimit from '@/lib/rate-limit'; +import { z } from 'zod'; +import { getRequestInfo } from '@/lib/network/get-client-ip'; + +// 클라이언트 로그 이벤트 스키마 +const clientLogSchema = z.object({ + event: z.enum(['download_attempt', 'download_success', 'download_error', 'security_violation']), + data: z.object({ + // 파일 관련 정보 + filePath: z.string(), + fileName: z.string(), + fileExtension: z.string().optional(), + fileSize: z.number().optional(), + duration: z.number().optional(), + + // 액션 관련 정보 + action: z.string().optional(), + + // 에러 관련 정보 + error: z.string().optional(), + + // 보안 위반 관련 정보 + type: z.string().optional(), // security_violation의 타입 + + // 클라이언트 환경 정보 + timestamp: z.string(), + userAgent: z.string(), + url: z.string(), + sessionId: z.string(), + + // 기타 정보 + userInitiated: z.boolean().optional(), + disableLogging: z.boolean().optional(), + }), +}); + + +// 클라이언트 로그를 파일 다운로드 로그 형식으로 변환 +function mapClientLogToDownloadLog(event: string, data: any, requestInfo: any, userId: string) { + // 파일 ID는 클라이언트에서 알 수 없으므로 0으로 설정 (또는 파일 경로로 조회 시도) + const fileId = 0; + + // 성공 여부 판단 + const success = event === 'download_success'; + + // 에러 메시지 설정 + let errorMessage = undefined; + if (event === 'download_error') { + errorMessage = data.error || 'Unknown client error'; + } else if (event === 'security_violation') { + errorMessage = `Security violation: ${data.type || 'unknown'} - ${data.error || ''}`; + } + + // 요청 ID 생성 (클라이언트 세션 ID 활용) + const requestId = data.sessionId || crypto.randomUUID(); + + return { + fileId, + success, + errorMessage, + requestId, + downloadDurationMs: data.duration, + fileInfo: { + fileName: data.fileName, + filePath: data.filePath, + fileSize: data.fileSize || 0, + }, + // 클라이언트 로그 추가 정보 + clientEventType: event, + clientUserAgent: data.userAgent, + clientUrl: data.url, + clientTimestamp: data.timestamp, + serverIp: requestInfo.ip, + serverUserAgent: requestInfo.userAgent, + }; +} + +export async function POST(request: NextRequest) { + const requestInfo = getRequestInfo(request); + + try { + // Rate limiting 체크 + const limiterResult = await rateLimit(request); + if (!limiterResult.success) { + console.warn('🚨 클라이언트 로그 Rate limit 초과:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + + return NextResponse.json( + { error: "Too many requests" }, + { status: 429 } + ); + } + + // 세션 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + console.warn('🚨 인증되지 않은 클라이언트 로그 요청:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // 요청 본문 파싱 및 검증 + const body = await request.json(); + const validatedData = clientLogSchema.parse(body); + + const { event, data } = validatedData; + + // 로깅이 비활성화된 경우 스킵 (하지만 보안 위반은 항상 기록) + if (data.disableLogging && event !== 'security_violation') { + return NextResponse.json({ success: true, logged: false }); + } + + // 클라이언트 로그를 서버 로그 형식으로 변환 + const downloadLogData = mapClientLogToDownloadLog(event, data, requestInfo, session.user.id); + + // 기존 파일 다운로드 로그 서비스 사용 + const result = await createFileDownloadLog(downloadLogData); + + if (!result.success) { + console.error('클라이언트 로그 저장 실패:', result.error); + return NextResponse.json( + { error: "Failed to save log" }, + { status: 500 } + ); + } + + // 성공 로그 (보안 위반은 경고 레벨로) + if (event === 'security_violation') { + console.warn('🚨 클라이언트 보안 위반 기록:', { + type: data.type, + filePath: data.filePath, + fileName: data.fileName, + userId: session.user.id, + ip: requestInfo.ip, + clientInfo: { + userAgent: data.userAgent, + url: data.url, + sessionId: data.sessionId, + } + }); + } else { + console.log('📝 클라이언트 로그 기록:', { + event, + fileName: data.fileName, + userId: session.user.id, + success: event === 'download_success', + logId: result.logId, + }); + } + + return NextResponse.json({ + success: true, + logged: true, + logId: result.logId + }); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error('❌ 클라이언트 로그 API 오류:', { + error: errorMessage, + userId: (await getServerSession(authOptions))?.user?.id, + ip: requestInfo.ip, + userAgent: requestInfo.userAgent, + }); + + // Zod 검증 에러 처리 + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Invalid log data', + details: error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ') + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined + }, + { status: 500 } + ); + } +} + +// GET 요청으로 클라이언트 로그 조회 (관리자용) +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user || !session.user.roles?.includes('admin')) { + return NextResponse.json( + { error: "Admin access required" }, + { status: 403 } + ); + } + + // 쿼리 파라미터 파싱 + const searchParams = request.nextUrl.searchParams; + const page = parseInt(searchParams.get('page') || '1', 10); + const limit = parseInt(searchParams.get('limit') || '50', 10); + const event = searchParams.get('event'); + const userId = searchParams.get('userId'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + // 여기서는 기존 로그 조회 함수를 사용하거나 + // 클라이언트 로그 전용 조회 함수를 만들어야 합니다 + // 현재는 간단한 응답으로 대체 + + return NextResponse.json({ + message: "Client logs retrieval - implement with your existing log service", + filters: { page, limit, event, userId, startDate, endDate } + }); + + } catch (error) { + console.error('❌ 클라이언트 로그 조회 오류:', error); + return NextResponse.json( + { error: 'Failed to retrieve logs' }, + { status: 500 } + ); + } +} + +// 클라이언트 로그 통계 조회 (관리자용) +export async function HEAD(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user || !session.user.roles?.includes('admin')) { + return new NextResponse(null, { status: 403 }); + } + + // 간단한 통계 정보를 헤더로 반환 + const headers = new Headers(); + headers.set('X-Total-Logs', '0'); // 실제 구현 필요 + headers.set('X-Security-Violations', '0'); // 실제 구현 필요 + headers.set('X-Success-Rate', '0%'); // 실제 구현 필요 + + return new NextResponse(null, { status: 200, headers }); + + } catch (error) { + console.error('❌ 클라이언트 로그 통계 조회 오류:', error); + return new NextResponse(null, { status: 500 }); + } +}
\ No newline at end of file diff --git a/app/api/notifications/[id]/deleate/route.ts b/app/api/notifications/[id]/deleate/route.ts new file mode 100644 index 00000000..21123c78 --- /dev/null +++ b/app/api/notifications/[id]/deleate/route.ts @@ -0,0 +1,34 @@ +// app/api/notifications/[id]/delete/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { deleteNotification } from '@/lib/notification/service'; + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const deletedNotification = await deleteNotification(params.id, session.user.id); + + if (!deletedNotification) { + return NextResponse.json( + { error: 'Notification not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting notification:', error); + return NextResponse.json( + { error: 'Failed to delete notification' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/notifications/[id]/read/route.ts b/app/api/notifications/[id]/read/route.ts new file mode 100644 index 00000000..ba894fad --- /dev/null +++ b/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,34 @@ +// app/api/notifications/[id]/read/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { markNotificationAsRead } from '@/lib/notification/service'; + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const notification = await markNotificationAsRead(params.id, session.user.id); + + if (!notification) { + return NextResponse.json( + { error: 'Notification not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true, notification }); + } catch (error) { + console.error('Error marking notification as read:', error); + return NextResponse.json( + { error: 'Failed to mark notification as read' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/notifications/read-all/route.ts b/app/api/notifications/read-all/route.ts new file mode 100644 index 00000000..f53bbbbf --- /dev/null +++ b/app/api/notifications/read-all/route.ts @@ -0,0 +1,26 @@ +// app/api/notifications/read-all/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +export async function PATCH(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const notifications = await markAllNotificationsAsRead(session.user.id); + + return NextResponse.json({ + success: true, + updatedCount: notifications.length + }); + } catch (error) { + console.error('Error marking all notifications as read:', error); + return NextResponse.json( + { error: 'Failed to mark all notifications as read' }, + { status: 500 } + ); + } +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 00000000..cab0b74e --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,37 @@ +// app/api/notifications/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { getUserNotifications,getUnreadNotificationCount } from '@/lib/notification/service'; +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '20'); + const offset = parseInt(searchParams.get('offset') || '0'); + const unreadOnly = searchParams.get('unreadOnly') === 'true'; + + const [notifications, unreadCount] = await Promise.all([ + getUserNotifications(session.user.id, { limit, offset, unreadOnly }), + getUnreadNotificationCount(session.user.id) + ]); + + return NextResponse.json({ + notifications, + unreadCount, + hasMore: notifications.length === limit + }); + } catch (error) { + console.error('Error fetching notifications:', error); + return NextResponse.json( + { error: 'Failed to fetch notifications' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/notifications/stats/route.ts b/app/api/notifications/stats/route.ts new file mode 100644 index 00000000..2e99eb3c --- /dev/null +++ b/app/api/notifications/stats/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import realtimeNotificationService from '@/lib/realtime/RealtimeNotificationService'; +import notificationManager from '@/lib/realtime/NotificationManager'; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.roles || !session.user.roles.includes('admin')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const stats = { + connectedClients: realtimeNotificationService.getConnectedClientCount(), + dbConnectionStatus: notificationManager.getConnectionStatus(), + timestamp: new Date().toISOString() + }; + + return NextResponse.json(stats); + } catch (error) { + console.error('Error fetching notification stats:', error); + return NextResponse.json( + { error: 'Failed to fetch stats' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/notifications/stream/route.ts b/app/api/notifications/stream/route.ts new file mode 100644 index 00000000..7ca6aab2 --- /dev/null +++ b/app/api/notifications/stream/route.ts @@ -0,0 +1,54 @@ +// app/api/notifications/stream/route.ts +import { NextRequest } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import realtimeNotificationService from '@/lib/realtime/RealtimeNotificationService'; + + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return new Response('Unauthorized', { status: 401 }); + } + + const userId = session.user.id; + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + // 클라이언트 등록 + const clientId = realtimeNotificationService.addClient(userId, controller); + + console.log(`SSE stream started for user ${userId} (${clientId})`); + + // 초기 연결 확인 메시지 + controller.enqueue(encoder.encode("data: {\"type\":\"connected\"}\n\n")); + + // 연결 해제 처리 + request.signal.addEventListener('abort', () => { + console.log(`SSE stream ended for user ${userId} (${clientId})`); + realtimeNotificationService.removeClient(userId, controller); + controller.close(); + }); + }, + + cancel() { + console.log(`SSE stream cancelled for user ${userId}`); + realtimeNotificationService.removeClient(userId, controller); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // Nginx 버퍼링 비활성화 + }, + }); + } catch (error) { + console.error('Error creating SSE stream:', error); + return new Response('Internal Server Error', { status: 500 }); + } + }
\ No newline at end of file diff --git a/app/api/rfq-attachments/download/route.ts b/app/api/rfq-attachments/download/route.ts index 05e87906..5a07bc0b 100644 --- a/app/api/rfq-attachments/download/route.ts +++ b/app/api/rfq-attachments/download/route.ts @@ -1,44 +1,161 @@ // app/api/rfq-attachments/download/route.ts import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants } from 'fs/promises'; -import { join } from 'path'; -import db from '@/db/db'; +import { readFile, access, constants, stat } from 'fs/promises'; +import { join, normalize, resolve } from 'path'; +import db from '@/db/db'; import { bRfqAttachmentRevisions, vendorResponseAttachmentsB } from '@/db/schema'; -import { eq, or } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { createFileDownloadLog } from '@/lib/file-download-log/service'; +import rateLimit from '@/lib/rate-limit'; +import { z } from 'zod'; +import { getRequestInfo } from '@/lib/network/get-client-ip'; + +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z' +]); + +// 최대 파일 크기 (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// 다운로드 요청 검증 스키마 +const downloadRequestSchema = z.object({ + path: z.string().min(1, 'File path is required'), + type: z.enum(['client', 'vendor']).optional(), + revisionId: z.string().optional(), + responseFileId: z.string().optional(), +}); + +// 파일 정보 타입 +interface FileRecord { + id: number; + fileName: string; + originalFileName?: string; + filePath: string; + fileSize: number; + fileType?: string; +} + +// 강화된 파일 경로 검증 함수 +function validateFilePath(filePath: string): boolean { + // null, undefined, 빈 문자열 체크 + if (!filePath || typeof filePath !== 'string') { + return false; + } + + // 위험한 패턴 체크 + const dangerousPatterns = [ + /\.\./, // 상위 디렉토리 접근 + /\/\//, // 이중 슬래시 + /[<>:"'|?*]/, // 특수문자 + /[\x00-\x1f]/, // 제어문자 + /\\+/ // 백슬래시 + ]; + + if (dangerousPatterns.some(pattern => pattern.test(filePath))) { + return false; + } + + // 시스템 파일 접근 방지 + const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; + for (const dangerousPath of dangerousPaths) { + if (filePath.toLowerCase().startsWith(dangerousPath)) { + return false; + } + } + + return true; +} + +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} + +// 안전한 파일명 생성 +function sanitizeFileName(fileName: string): string { + return fileName + .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거 + .replace(/\s+/g, '_') // 공백을 언더스코어로 + .substring(0, 255); // 파일명 길이 제한 +} export async function GET(request: NextRequest) { + const startTime = Date.now(); + const requestInfo = getRequestInfo(request); + let fileRecord: FileRecord | null = null; + try { + // Rate limiting 체크 + const limiterResult = await rateLimit(request); + if (!limiterResult.success) { + console.warn('🚨 Rate limit 초과:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + + return NextResponse.json( + { error: "Too many requests" }, + { status: 429 } + ); + } + // 세션 확인 const session = await getServerSession(authOptions); if (!session?.user) { + console.warn('🚨 인증되지 않은 다운로드 시도:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent, + path: request.nextUrl.searchParams.get("path") + }); + return NextResponse.json( { error: "Unauthorized" }, { status: 401 } ); } - // 파라미터 추출 - const path = request.nextUrl.searchParams.get("path"); - const type = request.nextUrl.searchParams.get("type"); // "client" | "vendor" - const revisionId = request.nextUrl.searchParams.get("revisionId"); - const responseFileId = request.nextUrl.searchParams.get("responseFileId"); + // 파라미터 검증 + const searchParams = { + path: request.nextUrl.searchParams.get("path"), + type: request.nextUrl.searchParams.get("type"), + revisionId: request.nextUrl.searchParams.get("revisionId"), + responseFileId: request.nextUrl.searchParams.get("responseFileId"), + }; + + const validatedParams = downloadRequestSchema.parse(searchParams); + const { path, type, revisionId, responseFileId } = validatedParams; - if (!path) { + // 파일 경로 보안 검증 + if (!validateFilePath(path)) { + console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, { + userId: session.user.id, + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + return NextResponse.json( - { error: "File path is required" }, + { error: "Invalid file path" }, { status: 400 } ); } + // 경로 정규화 + const normalizedPath = normalize(path.replace(/^\/+/, "")); + // DB에서 파일 정보 조회 - let dbRecord = null; + let dbRecord: FileRecord | null = null; if (type === "client" && revisionId) { // 발주처 첨부파일 리비전 const [record] = await db .select({ + id: bRfqAttachmentRevisions.id, fileName: bRfqAttachmentRevisions.fileName, originalFileName: bRfqAttachmentRevisions.originalFileName, filePath: bRfqAttachmentRevisions.filePath, @@ -54,6 +171,7 @@ export async function GET(request: NextRequest) { // 벤더 응답 파일 const [record] = await db .select({ + id: vendorResponseAttachmentsB.id, fileName: vendorResponseAttachmentsB.fileName, originalFileName: vendorResponseAttachmentsB.originalFileName, filePath: vendorResponseAttachmentsB.filePath, @@ -66,9 +184,10 @@ export async function GET(request: NextRequest) { dbRecord = record; } else { - // filePath로 직접 검색 (fallback) + // filePath로 직접 검색 (fallback) - 정규화된 경로로 검색 const [clientRecord] = await db .select({ + id: bRfqAttachmentRevisions.id, fileName: bRfqAttachmentRevisions.fileName, originalFileName: bRfqAttachmentRevisions.originalFileName, filePath: bRfqAttachmentRevisions.filePath, @@ -76,7 +195,7 @@ export async function GET(request: NextRequest) { fileType: bRfqAttachmentRevisions.fileType, }) .from(bRfqAttachmentRevisions) - .where(eq(bRfqAttachmentRevisions.filePath, path)); + .where(eq(bRfqAttachmentRevisions.filePath, normalizedPath)); if (clientRecord) { dbRecord = clientRecord; @@ -84,6 +203,7 @@ export async function GET(request: NextRequest) { // 벤더 파일에서도 검색 const [vendorRecord] = await db .select({ + id: vendorResponseAttachmentsB.id, fileName: vendorResponseAttachmentsB.fileName, originalFileName: vendorResponseAttachmentsB.originalFileName, filePath: vendorResponseAttachmentsB.filePath, @@ -91,72 +211,142 @@ export async function GET(request: NextRequest) { fileType: vendorResponseAttachmentsB.fileType, }) .from(vendorResponseAttachmentsB) - .where(eq(vendorResponseAttachmentsB.filePath, path)); + .where(eq(vendorResponseAttachmentsB.filePath, normalizedPath)); dbRecord = vendorRecord; } } - // 파일 정보 설정 - let fileName; - let fileType; + // DB에서 파일 정보를 찾지 못한 경우 + if (!dbRecord) { + console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", { + path, + normalizedPath, + userId: session.user.id, + ip: requestInfo.ip + }); - if (dbRecord) { - // DB에서 찾은 경우 원본 파일명 사용 - fileName = dbRecord.originalFileName || dbRecord.fileName; - fileType = dbRecord.fileType; - console.log("✅ DB에서 파일 정보 찾음:", { fileName, fileType, path: dbRecord.filePath }); - } else { - // DB에서 찾지 못한 경우 경로에서 파일명 추출 - fileName = path.split('/').pop() || 'download'; - console.log("⚠️ DB에서 파일 정보를 찾지 못함, 경로에서 추출:", fileName); + return NextResponse.json( + { error: "File not found in database" }, + { status: 404 } + ); + } + + fileRecord = dbRecord; + + // 파일명 설정 + const fileName = dbRecord.originalFileName || dbRecord.fileName; + + // 파일 확장자 검증 + if (!validateFileExtension(fileName)) { + console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File type not allowed', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: 0, + } + }); + + return NextResponse.json( + { error: "File type not allowed" }, + { status: 403 } + ); } - // 파일 경로 구성 - const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거 + // 안전한 파일 경로 구성 + const allowedDirs = ["public", "uploads", "storage"]; + let actualPath: string | null = null; + let baseDir: string | null = null; - // 가능한 파일 경로들 - const possiblePaths = [ - join(process.cwd(), "public", storedPath), - join(process.cwd(), "uploads", storedPath), - join(process.cwd(), "storage", storedPath), - join(process.cwd(), storedPath), // 절대 경로인 경우 - ]; + // 각 허용된 디렉터리에서 파일 찾기 + for (const dir of allowedDirs) { + baseDir = resolve(process.cwd(), dir); + const testPath = resolve(baseDir, normalizedPath); + + // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 + if (!testPath.startsWith(baseDir)) { + continue; + } - // 실제 파일 찾기 - let actualPath = null; - for (const testPath of possiblePaths) { try { await access(testPath, constants.R_OK); actualPath = testPath; console.log("✅ 파일 발견:", testPath); break; } catch (err) { - // 조용히 다음 경로 시도 + // 조용히 다음 디렉터리 시도 } } - if (!actualPath) { - console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", possiblePaths); + if (!actualPath || !baseDir) { + console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { + normalizedPath, + userId: session.user.id, + requestedPath: path + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File not found on server', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: dbRecord.fileSize || 0, + } + }); + return NextResponse.json( - { - error: "File not found on server", - details: { - path: path, - fileName: fileName, - triedPaths: possiblePaths - } - }, + { error: "File not found on server" }, { status: 404 } ); } + // 파일 크기 확인 + const stats = await stat(actualPath); + if (stats.size > MAX_FILE_SIZE) { + console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File too large', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: stats.size, + } + }); + + return NextResponse.json( + { error: "File too large" }, + { status: 413 } + ); + } + // 파일 읽기 const fileBuffer = await readFile(actualPath); // MIME 타입 결정 const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - let contentType = fileType || 'application/octet-stream'; // DB의 fileType 우선 사용 + let contentType = dbRecord.fileType || 'application/octet-stream'; // 확장자에 따른 MIME 타입 매핑 (fallback) if (!contentType || contentType === 'application/octet-stream') { @@ -168,8 +358,8 @@ export async function GET(request: NextRequest) { 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ppt': 'application/vnd.ms-powerpoint', 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', + 'txt': 'text/plain; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', @@ -186,23 +376,44 @@ export async function GET(request: NextRequest) { contentType = mimeTypes[fileExtension] || 'application/octet-stream'; } - // 안전한 파일명 생성 (특수문자 처리) - const safeFileName = fileName.replace(/[^\w\s.-]/gi, '_'); + // 안전한 파일명 생성 + const safeFileName = sanitizeFileName(fileName); - // 다운로드용 헤더 설정 + // 보안 헤더와 다운로드용 헤더 설정 const headers = new Headers(); headers.set('Content-Type', contentType); headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`); headers.set('Content-Length', fileBuffer.length.toString()); + + // 보안 헤더 headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); headers.set('Pragma', 'no-cache'); headers.set('Expires', '0'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // 성공 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: true, + requestId: requestInfo.requestId, + fileInfo: { + fileName: safeFileName, + filePath: path, + fileSize: fileBuffer.length, + } + }); console.log("✅ 파일 다운로드 성공:", { fileName: safeFileName, contentType, size: fileBuffer.length, - actualPath + actualPath, + userId: session.user.id, + ip: requestInfo.ip, + downloadDurationMs: Date.now() - startTime }); return new NextResponse(fileBuffer, { @@ -211,11 +422,51 @@ export async function GET(request: NextRequest) { }); } catch (error) { - console.error('❌ RFQ 첨부파일 다운로드 오류:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error('❌ RFQ 첨부파일 다운로드 오류:', { + error: errorMessage, + userId: (await getServerSession(authOptions))?.user?.id, + ip: requestInfo.ip, + path: request.nextUrl.searchParams.get("path"), + downloadDurationMs: Date.now() - startTime + }); + + // 에러 로그 기록 + if (fileRecord?.id) { + try { + await createFileDownloadLog({ + fileId: fileRecord.id, + success: false, + errorMessage, + requestId: requestInfo.requestId, + fileInfo: { + fileName: fileRecord.fileName || 'unknown', + filePath: request.nextUrl.searchParams.get("path") || '', + fileSize: fileRecord.fileSize || 0, + } + }); + } catch (logError) { + console.error('로그 기록 실패:', logError); + } + } + + // Zod 검증 에러 처리 + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Invalid request parameters', + details: error.errors.map(e => e.message).join(', ') + }, + { status: 400 } + ); + } + + // 에러 정보 최소화 (정보 노출 방지) return NextResponse.json( { - error: 'Failed to download file', - details: error instanceof Error ? error.message : String(error) + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined }, { status: 500 } ); diff --git a/app/api/rfq-download/route.ts b/app/api/rfq-download/route.ts index 19991128..92607e05 100644 --- a/app/api/rfq-download/route.ts +++ b/app/api/rfq-download/route.ts @@ -1,85 +1,174 @@ // app/api/rfq-download/route.ts import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants } from 'fs/promises'; -import { join } from 'path'; +import { readFile, access, constants, stat } from 'fs/promises'; +import { join, normalize, resolve } from 'path'; import db from '@/db/db'; import { rfqAttachments } from '@/db/schema/rfq'; import { eq } from 'drizzle-orm'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import rateLimit from '@/lib/rate-limit'; // Rate limiting 함수 +import { createFileDownloadLog } from '@/lib/file-download-log/service'; + +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif' +]); + +// 최대 파일 크기 (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// 파일 경로 검증 함수 +function validateFilePath(filePath: string): boolean { + // null, undefined, 빈 문자열 체크 + if (!filePath || typeof filePath !== 'string') { + return false; + } + + // 위험한 패턴 체크 + const dangerousPatterns = [ + /\.\./, // 상위 디렉토리 접근 + /\/\//, // 이중 슬래시 + /[<>:"'|?*]/, // 특수문자 + /[\x00-\x1f]/, // 제어문자 + /^\/+/, // 절대경로 + /\\+/ // 백슬래시 + ]; + + return !dangerousPatterns.some(pattern => pattern.test(filePath)); +} + +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} + +// 안전한 파일명 생성 +function sanitizeFileName(fileName: string): string { + return fileName + .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거 + .replace(/\s+/g, '_') // 공백을 언더스코어로 + .substring(0, 255); // 파일명 길이 제한 +} + + export async function GET(request: NextRequest) { try { - // 파일 경로 파라미터 받기 - const path = request.nextUrl.searchParams.get("path"); - - if (!path) { + // Rate limiting 체크 + const limiterResult = await rateLimit(request); + if (!limiterResult.success) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429 } + ); + } + + // 사용자 인증 확인 + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // 파일 경로 파라미터 받기 및 검증 + const rawPath = request.nextUrl.searchParams.get("path"); + + if (!rawPath) { return NextResponse.json( { error: "File path is required" }, { status: 400 } ); } - - // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색) + + // 파일 경로 검증 + if (!validateFilePath(rawPath)) { + return NextResponse.json( + { error: "Invalid file path" }, + { status: 400 } + ); + } + + // 경로 정규화 + const normalizedPath = normalize(rawPath.replace(/^\/+/, "")); + + // DB에서 파일 정보 조회 const [dbRecord] = await db .select({ + id: rfqAttachments.id, fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath + filePath: rfqAttachments.filePath, }) .from(rfqAttachments) - .where(eq(rfqAttachments.filePath, path)); - - // 파일 정보 설정 - let fileName; - - if (dbRecord) { - // DB에서 찾은 경우 원본 파일명 사용 - fileName = dbRecord.fileName; - console.log("DB에서 원본 파일명 찾음:", fileName); - } else { - // DB에서 찾지 못한 경우 경로에서 파일명 추출 - fileName = path.split('/').pop() || 'download'; + .where(eq(rfqAttachments.filePath, normalizedPath)); + + if (!dbRecord) { + return NextResponse.json( + { error: "File not found in database" }, + { status: 404 } + ); } - - // 파일 경로 구성 - const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거 - - // 파일 경로 시도 - const possiblePaths = [ - join(process.cwd(), "public", storedPath) - ]; - - // 실제 파일 찾기 - let actualPath = null; - for (const testPath of possiblePaths) { - try { - await access(testPath, constants.R_OK); - actualPath = testPath; - break; - } catch (err) { - console.log("❌ 경로에 파일 없음:", testPath); - } + + // 파일 확장자 검증 + if (!validateFileExtension(dbRecord.fileName)) { + return NextResponse.json( + { error: "File type not allowed" }, + { status: 403 } + ); + } + + // 사용자 권한 확인 + const userId = session.user?.id || session.user?.email; + if (!userId) { + return NextResponse.json( + { error: "User ID not found" }, + { status: 400 } + ); + } + + // 안전한 파일 경로 구성 + const publicDir = resolve(process.cwd(), "public"); + const fullPath = resolve(publicDir, normalizedPath); + + // 경로 탐색 공격 방지 - public 디렉토리 외부 접근 차단 + if (!fullPath.startsWith(publicDir)) { + return NextResponse.json( + { error: "Access denied" }, + { status: 403 } + ); } - - if (!actualPath) { + + // 파일 존재 및 읽기 권한 확인 + try { + await access(fullPath, constants.R_OK); + } catch (error) { return NextResponse.json( - { - error: "File not found on server", - details: { - path: path, - triedPaths: possiblePaths - } - }, + { error: "File not accessible" }, { status: 404 } ); } - - const fileBuffer = await readFile(actualPath); - + + // 파일 크기 확인 + const stats = await stat(fullPath); + if (stats.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File too large" }, + { status: 413 } + ); + } + + // 파일 읽기 + const fileBuffer = await readFile(fullPath); + + // 안전한 파일명 생성 + const safeFileName = sanitizeFileName(dbRecord.fileName); + // MIME 타입 결정 - const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - - let contentType = 'application/octet-stream'; // 기본 바이너리 - - // 확장자에 따른 MIME 타입 매핑 + const fileExtension = safeFileName.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record<string, string> = { 'pdf': 'application/pdf', 'doc': 'application/msword', @@ -88,33 +177,68 @@ export async function GET(request: NextRequest) { 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ppt': 'application/vnd.ms-powerpoint', 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', + 'txt': 'text/plain; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', }; - - contentType = mimeTypes[fileExtension] || contentType; - - // 다운로드용 헤더 설정 + + const contentType = mimeTypes[fileExtension] || 'application/octet-stream'; + + // 보안 헤더 설정 const headers = new Headers(); headers.set('Content-Type', contentType); - headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); + headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`); headers.set('Content-Length', fileBuffer.length.toString()); - + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + // 다운로드 로그 기록 (보안 감사를 위해) + console.log(`파일 다운로드: ${userId} - ${safeFileName} - ${new Date().toISOString()}`); + + // 감사 로그 기록 + await createFileDownloadLog({ fileId: dbRecord.id, success: true, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } }); + return new NextResponse(fileBuffer, { status: 200, headers, }); + } catch (error) { console.error('❌ RFQ 파일 다운로드 오류:', error); + + // 실패 감사 로그 기록 + try { + const rawPath = request.nextUrl.searchParams.get("path"); + if (rawPath) { + const normalizedPath = normalize(rawPath.replace(/^\/+/, "")); + const [dbRecord] = await db + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + }) + .from(rfqAttachments) + .where(eq(rfqAttachments.filePath, normalizedPath)); + + if (dbRecord) { + await createFileDownloadLog({ fileId: dbRecord.id, success: false, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } }); + + } + } + } catch (logError) { + console.error('감사 로그 기록 실패:', logError); + } + + // 에러 정보 최소화 (정보 노출 방지) return NextResponse.json( - { - error: 'Failed to download file', - details: String(error) - }, + { error: 'Internal server error' }, { status: 500 } ); } diff --git a/app/api/tbe-download/route.ts b/app/api/tbe-download/route.ts index 12e42920..93eb62db 100644 --- a/app/api/tbe-download/route.ts +++ b/app/api/tbe-download/route.ts @@ -1,119 +1,415 @@ -// app/api/rfq-download/route.ts +// app/api/tbe-download/route.ts import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants } from 'fs/promises'; -import { join } from 'path'; +import { readFile, access, constants, stat } from 'fs/promises'; +import { join, normalize, resolve } from 'path'; import db from '@/db/db'; import { rfqAttachments, vendorResponseAttachments } from '@/db/schema/rfq'; import { eq } from 'drizzle-orm'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { createFileDownloadLog } from '@/lib/file-download-log/service'; +import rateLimit from '@/lib/rate-limit'; +import { z } from 'zod'; +import { getRequestInfo } from '@/lib/network/get-client-ip'; + +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z' +]); + +// 최대 파일 크기 (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// 다운로드 요청 검증 스키마 +const downloadRequestSchema = z.object({ + path: z.string().min(1, 'File path is required'), +}); + +// 파일 정보 타입 +interface FileRecord { + id: number; + fileName: string; + filePath: string; + fileSize?: number; + fileType?: string; +} + + +// 강화된 파일 경로 검증 함수 +function validateFilePath(filePath: string): boolean { + // null, undefined, 빈 문자열 체크 + if (!filePath || typeof filePath !== 'string') { + return false; + } + + // 위험한 패턴 체크 + const dangerousPatterns = [ + /\.\./, // 상위 디렉토리 접근 + /\/\//, // 이중 슬래시 + /[<>:"'|?*]/, // 특수문자 + /[\x00-\x1f]/, // 제어문자 + /\\+/ // 백슬래시 + ]; + + if (dangerousPatterns.some(pattern => pattern.test(filePath))) { + return false; + } + + // 시스템 파일 접근 방지 + const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; + for (const dangerousPath of dangerousPaths) { + if (filePath.toLowerCase().startsWith(dangerousPath)) { + return false; + } + } + + return true; +} + +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} + +// 안전한 파일명 생성 +function sanitizeFileName(fileName: string): string { + return fileName + .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거 + .replace(/\s+/g, '_') // 공백을 언더스코어로 + .substring(0, 255); // 파일명 길이 제한 +} export async function GET(request: NextRequest) { + const startTime = Date.now(); + const requestInfo = getRequestInfo(request); + let fileRecord: FileRecord | null = null; + try { - // 파일 경로 파라미터 받기 - const path = request.nextUrl.searchParams.get("path"); - - if (!path) { + // Rate limiting 체크 + const limiterResult = await rateLimit(request); + if (!limiterResult.success) { + console.warn('🚨 Rate limit 초과:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + + return NextResponse.json( + { error: "Too many requests" }, + { status: 429 } + ); + } + + // 세션 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + console.warn('🚨 인증되지 않은 다운로드 시도:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent, + path: request.nextUrl.searchParams.get("path") + }); + + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // 파라미터 검증 + const searchParams = { + path: request.nextUrl.searchParams.get("path"), + }; + + const validatedParams = downloadRequestSchema.parse(searchParams); + const { path } = validatedParams; + + // 파일 경로 보안 검증 + if (!validateFilePath(path)) { + console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, { + userId: session.user.id, + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + return NextResponse.json( - { error: "File path is required" }, + { error: "Invalid file path" }, { status: 400 } ); } - + + // 경로 정규화 + const normalizedPath = normalize(path.replace(/^\/+/, "")); + // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색) const [dbRecord] = await db .select({ + id: vendorResponseAttachments.id, fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath + filePath: vendorResponseAttachments.filePath, + fileType: vendorResponseAttachments.fileType, }) .from(vendorResponseAttachments) - .where(eq(vendorResponseAttachments.filePath, path)); - - // 파일 정보 설정 - let fileName; - - if (dbRecord) { - // DB에서 찾은 경우 원본 파일명 사용 - fileName = dbRecord.fileName; - console.log("DB에서 원본 파일명 찾음:", fileName); - } else { - // DB에서 찾지 못한 경우 경로에서 파일명 추출 - fileName = path.split('/').pop() || 'download'; + .where(eq(vendorResponseAttachments.filePath, normalizedPath)); + + // DB에서 파일 정보를 찾지 못한 경우 + if (!dbRecord) { + console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", { + path, + normalizedPath, + userId: session.user.id, + ip: requestInfo.ip + }); + + return NextResponse.json( + { error: "File not found in database" }, + { status: 404 } + ); } - - // 파일 경로 구성 - const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거 - - // 파일 경로 시도 - const possiblePaths = [ - join(process.cwd(), "public", storedPath) - ]; - - // 실제 파일 찾기 - let actualPath = null; - for (const testPath of possiblePaths) { + + fileRecord = dbRecord; + + // 파일명 설정 + const fileName = dbRecord.fileName; + + // 파일 확장자 검증 + if (!validateFileExtension(fileName)) { + console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File type not allowed', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: 0, + } + }); + + return NextResponse.json( + { error: "File type not allowed" }, + { status: 403 } + ); + } + + // 안전한 파일 경로 구성 + const allowedDirs = ["public", "uploads", "storage"]; + let actualPath: string | null = null; + let baseDir: string | null = null; + + // 각 허용된 디렉터리에서 파일 찾기 + for (const dir of allowedDirs) { + baseDir = resolve(process.cwd(), dir); + const testPath = resolve(baseDir, normalizedPath); + + // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 + if (!testPath.startsWith(baseDir)) { + continue; + } + try { await access(testPath, constants.R_OK); actualPath = testPath; + console.log("✅ 파일 발견:", testPath); break; } catch (err) { console.log("❌ 경로에 파일 없음:", testPath); } } - - if (!actualPath) { + + if (!actualPath || !baseDir) { + console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { + normalizedPath, + userId: session.user.id, + requestedPath: path, + triedDirs: allowedDirs + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File not found on server', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: dbRecord.fileSize || 0, + } + }); + return NextResponse.json( - { + { error: "File not found on server", details: { path: path, - triedPaths: possiblePaths + fileName: fileName, } }, { status: 404 } ); } - + + // 파일 크기 확인 + const stats = await stat(actualPath); + if (stats.size > MAX_FILE_SIZE) { + console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File too large', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: stats.size, + } + }); + + return NextResponse.json( + { error: "File too large" }, + { status: 413 } + ); + } + + // 파일 읽기 const fileBuffer = await readFile(actualPath); - + // MIME 타입 결정 const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - - let contentType = 'application/octet-stream'; // 기본 바이너리 - - // 확장자에 따른 MIME 타입 매핑 - const mimeTypes: 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', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - }; - - contentType = mimeTypes[fileExtension] || contentType; - - // 다운로드용 헤더 설정 + let contentType = dbRecord.fileType || 'application/octet-stream'; + + // 확장자에 따른 MIME 타입 매핑 (fallback) + if (!contentType || contentType === 'application/octet-stream') { + const mimeTypes: 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', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt': 'text/plain; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml', + 'dwg': 'application/acad', + 'dxf': 'application/dxf', + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + }; + + contentType = mimeTypes[fileExtension] || 'application/octet-stream'; + } + + // 안전한 파일명 생성 + const safeFileName = sanitizeFileName(fileName); + + // 보안 헤더와 다운로드용 헤더 설정 const headers = new Headers(); headers.set('Content-Type', contentType); - headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); + headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`); headers.set('Content-Length', fileBuffer.length.toString()); + // 보안 헤더 + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // 성공 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: true, + requestId: requestInfo.requestId, + fileInfo: { + fileName: safeFileName, + filePath: path, + fileSize: fileBuffer.length, + } + }); + + console.log("✅ TBE 파일 다운로드 성공:", { + fileName: safeFileName, + contentType, + size: fileBuffer.length, + actualPath, + userId: session.user.id, + ip: requestInfo.ip, + downloadDurationMs: Date.now() - startTime + }); + return new NextResponse(fileBuffer, { status: 200, headers, }); + } catch (error) { - console.error('❌ RFQ 파일 다운로드 오류:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error('❌ TBE 파일 다운로드 오류:', { + error: errorMessage, + userId: (await getServerSession(authOptions))?.user?.id, + ip: requestInfo.ip, + path: request.nextUrl.searchParams.get("path"), + downloadDurationMs: Date.now() - startTime + }); + + // 에러 로그 기록 + if (fileRecord?.id) { + try { + await createFileDownloadLog({ + fileId: fileRecord.id, + success: false, + errorMessage, + requestId: requestInfo.requestId, + fileInfo: { + fileName: fileRecord.fileName || 'unknown', + filePath: request.nextUrl.searchParams.get("path") || '', + fileSize: fileRecord.fileSize || 0, + } + }); + } catch (logError) { + console.error('로그 기록 실패:', logError); + } + } + + // Zod 검증 에러 처리 + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Invalid request parameters', + details: error.errors.map(e => e.message).join(', ') + }, + { status: 400 } + ); + } + + // 에러 정보 최소화 (정보 노출 방지) return NextResponse.json( - { - error: 'Failed to download file', - details: String(error) + { + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined }, { status: 500 } ); diff --git a/app/api/tech-sales-rfq-download/route.ts b/app/api/tech-sales-rfq-download/route.ts index b9dd14d1..e0219787 100644 --- a/app/api/tech-sales-rfq-download/route.ts +++ b/app/api/tech-sales-rfq-download/route.ts @@ -1,85 +1,379 @@ -import { NextRequest } from "next/server" -import { join } from "path" -import { readFile } from "fs/promises" +import { NextRequest, NextResponse } from "next/server"; +import { join, normalize, resolve } from "path"; +import { readFile, access, constants, stat } from "fs/promises"; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { createFileDownloadLog } from '@/lib/file-download-log/service'; +import rateLimit from '@/lib/rate-limit'; +import { z } from 'zod'; +import { getRequestInfo } from "@/lib/network/get-client-ip"; -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams - const filePath = searchParams.get("path") +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z' +]); - if (!filePath) { - return new Response("File path is required", { status: 400 }) - } +// 최대 파일 크기 (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// 다운로드 요청 검증 스키마 +const downloadRequestSchema = z.object({ + path: z.string() + .min(1, 'File path is required') + .refine(path => path.startsWith("/techsales-rfq/"), { + message: 'Invalid file path - must start with /techsales-rfq/' + }) + .refine(path => !path.includes(".."), { + message: 'Invalid file path - directory traversal not allowed' + }), +}); + + +// 강화된 파일 경로 검증 +function validateFilePath(filePath: string): boolean { + // null, undefined, 빈 문자열 체크 + if (!filePath || typeof filePath !== 'string') { + return false; + } + + // 위험한 패턴 체크 + const dangerousPatterns = [ + /\.\./, // 상위 디렉토리 접근 + /\/\//, // 이중 슬래시 + /[<>:"'|?*]/, // 특수문자 + /[\x00-\x1f]/, // 제어문자 + /\\+/ // 백슬래시 + ]; - // 보안: 경로 조작 방지 - if (filePath.includes("..") || !filePath.startsWith("/techsales-rfq/")) { - return new Response("Invalid file path", { status: 400 }) + if (dangerousPatterns.some(pattern => pattern.test(filePath))) { + return false; + } + + // techsales-rfq 경로만 허용 + if (!filePath.startsWith('/techsales-rfq/')) { + return false; + } + + // 시스템 파일 접근 방지 + const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; + for (const dangerousPath of dangerousPaths) { + if (filePath.toLowerCase().includes(dangerousPath)) { + return false; } + } + + return true; +} - // 파일 경로 구성 (public 폴더 기준) - const fullPath = join(process.cwd(), "public", filePath) +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} - try { - // 파일 읽기 - const fileBuffer = await readFile(fullPath) +// 안전한 파일명 생성 +function sanitizeFileName(fileName: string): string { + return fileName + .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거 + .replace(/\s+/g, '_') // 공백을 언더스코어로 + .substring(0, 255); // 파일명 길이 제한 +} + +// MIME 타입 결정 +function getMimeType(fileName: string): string { + const ext = fileName.split(".").pop()?.toLowerCase(); + + const mimeTypes: 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', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt': 'text/plain; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml', + 'dwg': 'application/acad', + 'dxf': 'application/dxf', + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + }; + + return mimeTypes[ext || ''] || 'application/octet-stream'; +} + +export async function GET(request: NextRequest) { + const startTime = Date.now(); + const requestInfo = getRequestInfo(request); + + try { + // Rate limiting 체크 + const limiterResult = await rateLimit(request); + if (!limiterResult.success) { + console.warn('🚨 Rate limit 초과:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); - // 파일명 추출 - const fileName = filePath.split("/").pop() || "download" + return NextResponse.json( + { error: "Too many requests" }, + { status: 429 } + ); + } + + // 세션 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + console.warn('🚨 인증되지 않은 다운로드 시도:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent, + path: request.nextUrl.searchParams.get("path") + }); - // MIME 타입 결정 - const ext = fileName.split(".").pop()?.toLowerCase() - let contentType = "application/octet-stream" + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // 파라미터 검증 + const searchParams = request.nextUrl.searchParams; + const filePath = searchParams.get("path"); + + const validatedParams = downloadRequestSchema.parse({ path: filePath }); + const { path } = validatedParams; + + // 추가 보안 검증 + if (!validateFilePath(path)) { + console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, { + userId: session.user.id, + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); - switch (ext) { - case "pdf": - contentType = "application/pdf" - break - case "doc": - contentType = "application/msword" - break - case "docx": - contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - break - case "xls": - contentType = "application/vnd.ms-excel" - break - case "xlsx": - contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - break - case "jpg": - case "jpeg": - contentType = "image/jpeg" - break - case "png": - contentType = "image/png" - break - case "gif": - contentType = "image/gif" - break - case "txt": - contentType = "text/plain" - break - case "zip": - contentType = "application/zip" - break - default: - contentType = "application/octet-stream" + return NextResponse.json( + { error: "Invalid file path" }, + { status: 400 } + ); + } + + // 경로 정규화 + const normalizedPath = normalize(path); + + // 파일명 추출 및 검증 + const fileName = path.split("/").pop() || "download"; + + // 파일 확장자 검증 + if (!validateFileExtension(fileName)) { + console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: 0, // techsales 파일은 별도 ID가 없으므로 0으로 기록 + success: false, + errorMessage: 'File type not allowed', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: 0, + } + }); + + return NextResponse.json( + { error: "File type not allowed" }, + { status: 403 } + ); + } + + // 안전한 파일 경로 구성 + const allowedDirs = ["public"]; + let actualPath: string | null = null; + let baseDir: string | null = null; + + // 허용된 디렉터리에서 파일 찾기 + for (const dir of allowedDirs) { + baseDir = resolve(process.cwd(), dir); + const testPath = resolve(baseDir, normalizedPath.substring(1)); // leading slash 제거 + + // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 + if (!testPath.startsWith(baseDir)) { + continue; + } + + try { + await access(testPath, constants.R_OK); + actualPath = testPath; + console.log("✅ 파일 발견:", testPath); + break; + } catch (err) { + // 조용히 다음 경로 시도 } + } + + if (!actualPath || !baseDir) { + console.error("❌ 파일 접근 불가:", { + normalizedPath, + userId: session.user.id, + requestedPath: path, + triedDirs: allowedDirs + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: 0, + success: false, + errorMessage: 'File not found on server', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: 0, + } + }); - // 응답 헤더 설정 - const headers = new Headers({ - "Content-Type": contentType, - "Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`, - "Content-Length": fileBuffer.length.toString(), - }) - - return new Response(fileBuffer, { headers }) - } catch (fileError) { - console.error("File read error:", fileError) - return new Response("File not found", { status: 404 }) + return NextResponse.json( + { error: "File not found" }, + { status: 404 } + ); } + + // 파일 크기 확인 + const stats = await stat(actualPath); + if (stats.size > MAX_FILE_SIZE) { + console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: 0, + success: false, + errorMessage: 'File too large', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: stats.size, + } + }); + + return NextResponse.json( + { error: "File too large" }, + { status: 413 } + ); + } + + // 파일 읽기 + const fileBuffer = await readFile(actualPath); + + // MIME 타입 결정 + const contentType = getMimeType(fileName); + + // 안전한 파일명 생성 (특수문자 처리) + const safeFileName = sanitizeFileName(fileName); + + // 보안 헤더와 다운로드용 헤더 설정 + const headers = new Headers({ + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`, + 'Content-Length': fileBuffer.length.toString(), + + // 보안 헤더 + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + }); + + // 성공 로그 기록 + await createFileDownloadLog({ + fileId: 0, // techsales 파일용 ID + success: true, + requestId: requestInfo.requestId, + fileInfo: { + fileName: safeFileName, + filePath: path, + fileSize: fileBuffer.length, + } + }); + + console.log("✅ 파일 다운로드 성공:", { + fileName: safeFileName, + contentType, + size: fileBuffer.length, + actualPath, + userId: session.user.id, + ip: requestInfo.ip, + downloadDurationMs: Date.now() - startTime + }); + + return new Response(fileBuffer, { headers }); + } catch (error) { - console.error("Download error:", error) - return new Response("Internal server error", { status: 500 }) + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error('❌ Techsales 파일 다운로드 오류:', { + error: errorMessage, + userId: (await getServerSession(authOptions))?.user?.id, + ip: requestInfo.ip, + path: request.nextUrl.searchParams.get("path"), + downloadDurationMs: Date.now() - startTime + }); + + // 에러 로그 기록 + try { + const filePath = request.nextUrl.searchParams.get("path") || ''; + const fileName = filePath.split("/").pop() || 'unknown'; + + await createFileDownloadLog({ + fileId: 0, + success: false, + errorMessage, + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath, + fileSize: 0, + } + }); + } catch (logError) { + console.error('로그 기록 실패:', logError); + } + + // Zod 검증 에러 처리 + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Invalid request parameters', + details: error.errors.map(e => e.message).join(', ') + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined + }, + { status: 500 } + ); } -}
\ No newline at end of file +}
\ No newline at end of file |
