// app/api/rfq-download/route.ts import { NextRequest, NextResponse } from 'next/server'; 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 { // 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 } ); } // 파일 경로 검증 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, }) .from(rfqAttachments) .where(eq(rfqAttachments.filePath, normalizedPath)); if (!dbRecord) { return NextResponse.json( { error: "File not found in database" }, { status: 404 } ); } // 파일 확장자 검증 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 } ); } // 파일 존재 및 읽기 권한 확인 try { await access(fullPath, constants.R_OK); } catch (error) { return NextResponse.json( { error: "File not accessible" }, { status: 404 } ); } // 파일 크기 확인 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 = safeFileName.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record = { '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', }; const contentType = mimeTypes[fileExtension] || 'application/octet-stream'; // 보안 헤더 설정 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('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: 'Internal server error' }, { status: 500 } ); } }