import { NextRequest, NextResponse } from 'next/server'; import { readFile, access, constants, stat } from 'fs/promises'; import { normalize, resolve } from 'path'; import db from '@/db/db'; import { rfqLastAttachmentRevisions } from '@/db/schema/rfqLast'; import { eq } from 'drizzle-orm'; // 허용된 파일 확장자 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' ]); // 최대 파일 크기 (10240MB) const MAX_FILE_SIZE = 10240 * 1024 * 1024; // 파일 경로 검증 함수 function validateFilePath(filePath: string): boolean { if (!filePath || typeof filePath !== 'string') { return false; } // 위험한 패턴 체크 const dangerousPatterns = [ /\.\./, // 상위 디렉토리 접근 /\/\//, // 이중 슬래시 /[<>:"'|?*]/, // 특수문자 /[\x00-\x1f]/, // 제어문자 /\\+/ // 백슬래시 ]; if (dangerousPatterns.some(pattern => pattern.test(filePath))) { return false; } return true; } // 파일 확장자 검증 function validateFileExtension(fileName: string): boolean { const extension = fileName.split('.').pop()?.toLowerCase() || ''; return ALLOWED_EXTENSIONS.has(extension); } export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; const relativePath = searchParams.get('path'); const revisionId = searchParams.get('revisionId'); if (!relativePath && !revisionId) { return NextResponse.json( { error: '파일 경로 또는 리비전 ID가 제공되지 않았습니다.' }, { status: 400 } ); } // 파일 경로 보안 검증 if (relativePath && !validateFilePath(relativePath)) { console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${relativePath}`); return NextResponse.json( { error: '잘못된 파일 경로입니다.' }, { status: 400 } ); } // DB에서 파일 정보 조회 type FileRecord = { fileName: string | null; filePath: string; fileSize: number | null; fileType: string | null; }; let dbRecord: FileRecord | undefined = undefined; let fileName = ''; let filePath = ''; if (revisionId) { // 리비전 ID로 조회 const records = await db .select({ fileName: rfqLastAttachmentRevisions.originalFileName, filePath: rfqLastAttachmentRevisions.filePath, fileSize: rfqLastAttachmentRevisions.fileSize, fileType: rfqLastAttachmentRevisions.fileType, }) .from(rfqLastAttachmentRevisions) .where(eq(rfqLastAttachmentRevisions.id, parseInt(revisionId))) .limit(1); dbRecord = records[0]; } else if (relativePath) { // 경로로 조회 (fallback) const normalizedPath = normalize(relativePath.replace(/^\/+/, "")); const records = await db .select({ fileName: rfqLastAttachmentRevisions.originalFileName, filePath: rfqLastAttachmentRevisions.filePath, fileSize: rfqLastAttachmentRevisions.fileSize, fileType: rfqLastAttachmentRevisions.fileType, }) .from(rfqLastAttachmentRevisions) .where(eq(rfqLastAttachmentRevisions.filePath, `uploads/pos/${normalizedPath}`)) .limit(1); dbRecord = records[0]; } if (!dbRecord) { return NextResponse.json( { error: '파일 정보를 찾을 수 없습니다.' }, { status: 404 } ); } fileName = dbRecord.fileName || relativePath?.split('/').pop() || 'download'; filePath = dbRecord.filePath; // 파일 확장자 검증 if (!validateFileExtension(fileName)) { return NextResponse.json( { error: '지원하지 않는 파일 형식입니다.' }, { status: 403 } ); } // 파일 경로 구성 및 보안 검증 // 일반 파일 다운로드 API와 동일한 방식으로 경로 찾기 const storedPath = filePath.replace(/^\/+/, ""); // 앞쪽 슬래시 제거 // 가능한 경로들에서 파일 찾기 const possiblePaths = [ resolve(process.cwd(), "public", storedPath), resolve(process.cwd(), storedPath), // public 없이도 시도 ]; let actualPath: string | null = null; for (const testPath of possiblePaths) { // 경로 탐색 공격 방지 const allowedBaseDir = resolve(process.cwd(), "uploads"); if (!testPath.startsWith(allowedBaseDir) && !testPath.startsWith(resolve(process.cwd(), "public"))) { continue; // 허용되지 않은 경로 } try { await access(testPath, constants.R_OK); actualPath = testPath; console.log("✅ POS 파일 발견:", testPath); break; } catch { // 조용히 다음 경로 시도 } } console.log(`🔧 경로 계산 상세:`, { cwd: process.cwd(), dbFilePath: filePath, storedPath, possiblePaths, foundPath: actualPath, platform: process.platform }); if (!actualPath) { console.warn("❌ 모든 경로에서 POS 파일을 찾을 수 없음:", { filePath, storedPath, possiblePaths }); return NextResponse.json( { error: '파일을 찾을 수 없습니다.' }, { status: 404 } ); } // actualPath는 이미 access()로 검증됨 // 파일 크기 확인 const stats = await stat(actualPath); if (stats.size > MAX_FILE_SIZE) { return NextResponse.json( { error: '파일 크기가 너무 큽니다.' }, { status: 413 } ); } // 파일 읽기 const fileBuffer = await readFile(actualPath); // MIME 타입 결정 const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; let contentType = dbRecord.fileType || 'application/octet-stream'; if (!contentType || contentType === 'application/octet-stream') { 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', '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 response = new NextResponse(Buffer.from(fileBuffer)); response.headers.set('Content-Type', contentType); response.headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`); response.headers.set('Content-Length', fileBuffer.length.toString()); // 보안 헤더 response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); response.headers.set('Pragma', 'no-cache'); response.headers.set('Expires', '0'); response.headers.set('X-Content-Type-Options', 'nosniff'); console.log(`✅ POS 파일 다운로드 성공: ${fileName} (${fileBuffer.length} bytes)`); return response; } catch (error) { console.error('❌ POS 파일 다운로드 API 오류:', error); return NextResponse.json( { error: '서버 내부 오류가 발생했습니다.' }, { status: 500 } ); } }