diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-20 11:21:10 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-20 11:21:10 +0900 |
| commit | b68d81f5a87e3278a02b915c2fc6ce0e62144865 (patch) | |
| tree | deb1a2ce6e804e4c452965adf184fe6f10a765ae /app/api/pos/download/route.ts | |
| parent | 89fa000062650474915d5156fd21c6a85a06608b (diff) | |
(김준회) pos 파일 다운로드 경로문제 해결
Diffstat (limited to 'app/api/pos/download/route.ts')
| -rw-r--r-- | app/api/pos/download/route.ts | 227 |
1 files changed, 212 insertions, 15 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 } |
