summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/api/pos/download/route.ts227
-rw-r--r--lib/pos/download-pos-file.ts7
-rw-r--r--lib/rfq-last/table/rfq-attachments-dialog.tsx65
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 {