diff options
| -rw-r--r-- | app/api/pos/download/route.ts | 227 | ||||
| -rw-r--r-- | lib/pos/download-pos-file.ts | 7 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-attachments-dialog.tsx | 65 |
3 files changed, 263 insertions, 36 deletions
diff --git a/app/api/pos/download/route.ts b/app/api/pos/download/route.ts index 5328dc9d..d2ee26e9 100644 --- a/app/api/pos/download/route.ts +++ b/app/api/pos/download/route.ts @@ -1,44 +1,241 @@ import { NextRequest, NextResponse } from 'next/server'; -import { downloadPosFile } from '@/lib/pos/download-pos-file'; +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) { + if (!relativePath && !revisionId) { return NextResponse.json( - { error: '파일 경로가 제공되지 않았습니다.' }, + { error: '파일 경로 또는 리비전 ID가 제공되지 않았습니다.' }, { status: 400 } ); } - const result = await downloadPosFile({ relativePath }); + // 파일 경로 보안 검증 + 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 (!result.success) { + if (!actualPath) { + console.warn("❌ 모든 경로에서 POS 파일을 찾을 수 없음:", { + filePath, + storedPath, + possiblePaths + }); return NextResponse.json( - { error: result.error }, + { error: '파일을 찾을 수 없습니다.' }, { status: 404 } ); } - if (!result.fileBuffer || !result.fileName) { + // actualPath는 이미 access()로 검증됨 + + // 파일 크기 확인 + const stats = await stat(actualPath); + if (stats.size > MAX_FILE_SIZE) { return NextResponse.json( - { error: '파일을 읽을 수 없습니다.' }, - { status: 500 } + { 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<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 response = new NextResponse(result.fileBuffer); - - response.headers.set('Content-Type', result.mimeType || 'application/octet-stream'); - response.headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.fileName)}"`); - response.headers.set('Content-Length', result.fileBuffer.length.toString()); + 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); + console.error('❌ POS 파일 다운로드 API 오류:', error); return NextResponse.json( { error: '서버 내부 오류가 발생했습니다.' }, { status: 500 } diff --git a/lib/pos/download-pos-file.ts b/lib/pos/download-pos-file.ts index d876c673..bd18f059 100644 --- a/lib/pos/download-pos-file.ts +++ b/lib/pos/download-pos-file.ts @@ -178,3 +178,10 @@ export async function createDownloadUrl(relativePath: string): Promise<string> { const encodedPath = encodeURIComponent(relativePath); return `/api/pos/download?path=${encodedPath}`; } + +/** + * POS 파일의 리비전 ID를 사용하여 다운로드 URL을 생성하는 헬퍼 함수 + */ +export async function createDownloadUrlByRevisionId(revisionId: number): Promise<string> { + return `/api/pos/download?revisionId=${revisionId}`; +} diff --git a/lib/rfq-last/table/rfq-attachments-dialog.tsx b/lib/rfq-last/table/rfq-attachments-dialog.tsx index 161e446a..103617b3 100644 --- a/lib/rfq-last/table/rfq-attachments-dialog.tsx +++ b/lib/rfq-last/table/rfq-attachments-dialog.tsx @@ -94,24 +94,38 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment setDownloadingFiles(prev => new Set([...prev, attachmentId])) try { - const result = await downloadFile( - attachment.filePath, - attachment.originalFileName, - { - action: 'download', - showToast: true, - showSuccessToast: true, - onSuccess: (fileName, fileSize) => { - console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`) - }, - onError: (error) => { - console.error(`다운로드 실패: ${error}`) + // POS 파일(설계 문서)은 별도 API 사용 + if (attachment.attachmentType === '설계') { + const downloadUrl = `/api/pos/download?revisionId=${attachment.attachmentId}` + const link = document.createElement('a') + link.href = downloadUrl + link.download = attachment.originalFileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + toast.success(`${attachment.originalFileName} 다운로드가 시작되었습니다.`) + } else { + // 일반 파일은 기존 downloadFile 함수 사용 + const result = await downloadFile( + attachment.filePath, + attachment.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onSuccess: (fileName, fileSize) => { + console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`) + }, + onError: (error) => { + console.error(`다운로드 실패: ${error}`) + } } - } - ) + ) - if (!result.success) { - console.error("다운로드 결과:", result) + if (!result.success) { + console.error("다운로드 결과:", result) + } } } catch (error) { console.error("파일 다운로드 오류:", error) @@ -128,15 +142,19 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment // 파일 미리보기 핸들러 const handlePreview = async (attachment: RfqAttachment) => { const fileInfo = getFileInfo(attachment.originalFileName) - - if (!fileInfo.canPreview) { - toast.info("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다.") + + // POS 파일(설계 문서)은 미리보기를 지원하지 않음 + if (attachment.attachmentType === '설계' || !fileInfo.canPreview) { + const message = attachment.attachmentType === '설계' + ? "POS 파일은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다." + : "이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다." + toast.info(message) return handleDownload(attachment) } try { const result = await quickPreview(attachment.filePath, attachment.originalFileName) - + if (!result.success) { console.error("미리보기 결과:", result) } @@ -150,7 +168,12 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment const handleSmartAction = async (attachment: RfqAttachment) => { const attachmentId = attachment.attachmentId const fileInfo = getFileInfo(attachment.originalFileName) - + + // POS 파일(설계 문서)은 미리보기를 지원하지 않으므로 바로 다운로드 + if (attachment.attachmentType === '설계') { + return handleDownload(attachment) + } + if (fileInfo.canPreview) { return handlePreview(attachment) } else { |
