import { NextRequest, NextResponse } from 'next/server'; import db from '@/db/db'; import { sql } from 'drizzle-orm'; import JSZip from 'jszip'; import fs from 'fs/promises'; import path from 'path'; export async function POST(request: NextRequest) { try { // 1. 요청 데이터 파싱 const body = await request.json(); const { rfqId, files } = body; // 2. 입력값 검증 if (!rfqId || !Array.isArray(files) || files.length === 0) { return NextResponse.json( { error: '유효하지 않은 요청입니다' }, { status: 400 } ); } // 파일 개수 제한 (보안) if (files.length > 100) { return NextResponse.json( { error: '한 번에 다운로드할 수 있는 최대 파일 수는 100개입니다' }, { status: 400 } ); } // 3. DB에서 RFQ 정보 확인 (권한 검증용) const rfqResult = await db.execute(sql` SELECT rfq_code, rfq_title FROM rfqs_last_view WHERE id = ${rfqId} `); if (rfqResult.rows.length === 0) { return NextResponse.json( { error: 'RFQ를 찾을 수 없습니다' }, { status: 404 } ); } const rfqInfo = rfqResult.rows[0] as any; // 4. NAS 경로 설정 const nasPath = process.env.NAS_PATH; if (!nasPath) { console.error('NAS_PATH 환경 변수가 설정되지 않았습니다'); return NextResponse.json( { error: '서버 설정 오류' }, { status: 500 } ); } // 5. ZIP 객체 생성 const zip = new JSZip(); let addedFiles = 0; const errors: string[] = []; // 6. 파일들을 ZIP에 추가 for (const file of files) { if (!file.path || !file.name) { errors.push(`잘못된 파일 정보: ${file.name || 'unknown'}`); continue; } try { // 경로 정규화 및 보안 검증 const filePath = file.path.startsWith('/') ? path.join(nasPath, file.path) : path.join(nasPath, '/', file.path); // 경로 탐색 공격 방지 // const normalizedPath = path.normalize(filePath); // if (!normalizedPath.startsWith(nasPath)) { // console.error(`보안 위반: 허용되지 않은 경로 접근 시도 - ${file.path}`); // errors.push(`보안 오류: ${file.name}`); // continue; // } // 파일 읽기 const fileContent = await fs.readFile(filePath); // 중복 파일명 처리 let fileName = file.name; let counter = 1; while (zip.file(fileName)) { const ext = path.extname(file.name); const baseName = path.basename(file.name, ext); fileName = `${baseName}_${counter}${ext}`; counter++; } // ZIP에 파일 추가 zip.file(fileName, fileContent, { binary: true, createFolders: false, date: new Date(), comment: `RFQ: ${rfqId}` }); addedFiles++; } catch (error) { console.error(`파일 처리 실패: ${file.name}`, error); errors.push(`파일 읽기 실패: ${file.name}`); } } // 7. 추가된 파일이 없으면 오류 if (addedFiles === 0) { return NextResponse.json( { error: '다운로드할 수 있는 파일이 없습니다', details: errors }, { status: 404 } ); } // 8. ZIP 파일 생성 (압축 레벨 설정) const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 // 최대 압축 }, comment: `RFQ ${rfqInfo.rfq_code} 첨부파일`, platform: 'UNIX' // 파일 권한 보존 }); // 9. 파일명 생성 const fileName = `RFQ_${rfqInfo.rfq_code}_attachments_${new Date().toISOString().slice(0, 10)}.zip`; // 10. 로그 기록 console.log(`ZIP 생성 완료: ${fileName}, 파일 수: ${addedFiles}/${files.length}, 크기: ${(zipBuffer.length / 1024).toFixed(2)}KB`); if (errors.length > 0) { console.warn('처리 중 오류 발생:', errors); } // 11. 응답 반환 return new NextResponse(zipBuffer, { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${fileName}"`, 'Content-Length': zipBuffer.length.toString(), 'Cache-Control': 'no-cache, no-store, must-revalidate', 'X-Files-Count': addedFiles.toString(), 'X-Total-Files': files.length.toString(), 'X-Errors-Count': errors.length.toString(), }, }); } catch (error) { console.error('전체 다운로드 오류:', error); return NextResponse.json( { error: '파일 다운로드 중 오류가 발생했습니다', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 } ); } } // OPTIONS 요청 처리 (CORS) export async function OPTIONS(request: NextRequest) { return new NextResponse(null, { status: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, }); }