summaryrefslogtreecommitdiff
path: root/app/api/rfq-download
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/rfq-download')
-rw-r--r--app/api/rfq-download/route.ts262
1 files changed, 193 insertions, 69 deletions
diff --git a/app/api/rfq-download/route.ts b/app/api/rfq-download/route.ts
index 19991128..92607e05 100644
--- a/app/api/rfq-download/route.ts
+++ b/app/api/rfq-download/route.ts
@@ -1,85 +1,174 @@
// app/api/rfq-download/route.ts
import { NextRequest, NextResponse } from 'next/server';
-import { readFile, access, constants } from 'fs/promises';
-import { join } from 'path';
+import { readFile, access, constants, stat } from 'fs/promises';
+import { join, normalize, resolve } from 'path';
import db from '@/db/db';
import { rfqAttachments } from '@/db/schema/rfq';
import { eq } from 'drizzle-orm';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import rateLimit from '@/lib/rate-limit'; // Rate limiting 함수
+import { createFileDownloadLog } from '@/lib/file-download-log/service';
+
+// 허용된 파일 확장자
+const ALLOWED_EXTENSIONS = new Set([
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
+ 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif'
+]);
+
+// 최대 파일 크기 (50MB)
+const MAX_FILE_SIZE = 50 * 1024 * 1024;
+
+// 파일 경로 검증 함수
+function validateFilePath(filePath: string): boolean {
+ // null, undefined, 빈 문자열 체크
+ if (!filePath || typeof filePath !== 'string') {
+ return false;
+ }
+
+ // 위험한 패턴 체크
+ const dangerousPatterns = [
+ /\.\./, // 상위 디렉토리 접근
+ /\/\//, // 이중 슬래시
+ /[<>:"'|?*]/, // 특수문자
+ /[\x00-\x1f]/, // 제어문자
+ /^\/+/, // 절대경로
+ /\\+/ // 백슬래시
+ ];
+
+ return !dangerousPatterns.some(pattern => pattern.test(filePath));
+}
+
+// 파일 확장자 검증
+function validateFileExtension(fileName: string): boolean {
+ const extension = fileName.split('.').pop()?.toLowerCase() || '';
+ return ALLOWED_EXTENSIONS.has(extension);
+}
+
+// 안전한 파일명 생성
+function sanitizeFileName(fileName: string): string {
+ return fileName
+ .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거
+ .replace(/\s+/g, '_') // 공백을 언더스코어로
+ .substring(0, 255); // 파일명 길이 제한
+}
+
+
export async function GET(request: NextRequest) {
try {
- // 파일 경로 파라미터 받기
- const path = request.nextUrl.searchParams.get("path");
-
- if (!path) {
+ // Rate limiting 체크
+ const limiterResult = await rateLimit(request);
+ if (!limiterResult.success) {
+ return NextResponse.json(
+ { error: "Too many requests" },
+ { status: 429 }
+ );
+ }
+
+ // 사용자 인증 확인
+ const session = await getServerSession(authOptions);
+ if (!session || !session.user) {
+ return NextResponse.json(
+ { error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // 파일 경로 파라미터 받기 및 검증
+ const rawPath = request.nextUrl.searchParams.get("path");
+
+ if (!rawPath) {
return NextResponse.json(
{ error: "File path is required" },
{ status: 400 }
);
}
-
- // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색)
+
+ // 파일 경로 검증
+ if (!validateFilePath(rawPath)) {
+ return NextResponse.json(
+ { error: "Invalid file path" },
+ { status: 400 }
+ );
+ }
+
+ // 경로 정규화
+ const normalizedPath = normalize(rawPath.replace(/^\/+/, ""));
+
+ // DB에서 파일 정보 조회
const [dbRecord] = await db
.select({
+ id: rfqAttachments.id,
fileName: rfqAttachments.fileName,
- filePath: rfqAttachments.filePath
+ filePath: rfqAttachments.filePath,
})
.from(rfqAttachments)
- .where(eq(rfqAttachments.filePath, path));
-
- // 파일 정보 설정
- let fileName;
-
- if (dbRecord) {
- // DB에서 찾은 경우 원본 파일명 사용
- fileName = dbRecord.fileName;
- console.log("DB에서 원본 파일명 찾음:", fileName);
- } else {
- // DB에서 찾지 못한 경우 경로에서 파일명 추출
- fileName = path.split('/').pop() || 'download';
+ .where(eq(rfqAttachments.filePath, normalizedPath));
+
+ if (!dbRecord) {
+ return NextResponse.json(
+ { error: "File not found in database" },
+ { status: 404 }
+ );
}
-
- // 파일 경로 구성
- const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거
-
- // 파일 경로 시도
- const possiblePaths = [
- join(process.cwd(), "public", storedPath)
- ];
-
- // 실제 파일 찾기
- let actualPath = null;
- for (const testPath of possiblePaths) {
- try {
- await access(testPath, constants.R_OK);
- actualPath = testPath;
- break;
- } catch (err) {
- console.log("❌ 경로에 파일 없음:", testPath);
- }
+
+ // 파일 확장자 검증
+ if (!validateFileExtension(dbRecord.fileName)) {
+ return NextResponse.json(
+ { error: "File type not allowed" },
+ { status: 403 }
+ );
+ }
+
+ // 사용자 권한 확인
+ const userId = session.user?.id || session.user?.email;
+ if (!userId) {
+ return NextResponse.json(
+ { error: "User ID not found" },
+ { status: 400 }
+ );
+ }
+
+ // 안전한 파일 경로 구성
+ const publicDir = resolve(process.cwd(), "public");
+ const fullPath = resolve(publicDir, normalizedPath);
+
+ // 경로 탐색 공격 방지 - public 디렉토리 외부 접근 차단
+ if (!fullPath.startsWith(publicDir)) {
+ return NextResponse.json(
+ { error: "Access denied" },
+ { status: 403 }
+ );
}
-
- if (!actualPath) {
+
+ // 파일 존재 및 읽기 권한 확인
+ try {
+ await access(fullPath, constants.R_OK);
+ } catch (error) {
return NextResponse.json(
- {
- error: "File not found on server",
- details: {
- path: path,
- triedPaths: possiblePaths
- }
- },
+ { error: "File not accessible" },
{ status: 404 }
);
}
-
- const fileBuffer = await readFile(actualPath);
-
+
+ // 파일 크기 확인
+ const stats = await stat(fullPath);
+ if (stats.size > MAX_FILE_SIZE) {
+ return NextResponse.json(
+ { error: "File too large" },
+ { status: 413 }
+ );
+ }
+
+ // 파일 읽기
+ const fileBuffer = await readFile(fullPath);
+
+ // 안전한 파일명 생성
+ const safeFileName = sanitizeFileName(dbRecord.fileName);
+
// MIME 타입 결정
- const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
-
- let contentType = 'application/octet-stream'; // 기본 바이너리
-
- // 확장자에 따른 MIME 타입 매핑
+ const fileExtension = safeFileName.split('.').pop()?.toLowerCase() || '';
const mimeTypes: Record<string, string> = {
'pdf': 'application/pdf',
'doc': 'application/msword',
@@ -88,33 +177,68 @@ export async function GET(request: NextRequest) {
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'txt': 'text/plain',
- 'csv': 'text/csv',
+ 'txt': 'text/plain; charset=utf-8',
+ 'csv': 'text/csv; charset=utf-8',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
};
-
- contentType = mimeTypes[fileExtension] || contentType;
-
- // 다운로드용 헤더 설정
+
+ const contentType = mimeTypes[fileExtension] || 'application/octet-stream';
+
+ // 보안 헤더 설정
const headers = new Headers();
headers.set('Content-Type', contentType);
- headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
+ headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`);
headers.set('Content-Length', fileBuffer.length.toString());
-
+ headers.set('X-Content-Type-Options', 'nosniff');
+ headers.set('X-Frame-Options', 'DENY');
+ headers.set('X-XSS-Protection', '1; mode=block');
+ headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
+ headers.set('Pragma', 'no-cache');
+ headers.set('Expires', '0');
+
+ // 다운로드 로그 기록 (보안 감사를 위해)
+ console.log(`파일 다운로드: ${userId} - ${safeFileName} - ${new Date().toISOString()}`);
+
+ // 감사 로그 기록
+ await createFileDownloadLog({ fileId: dbRecord.id, success: true, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } });
+
return new NextResponse(fileBuffer, {
status: 200,
headers,
});
+
} catch (error) {
console.error('❌ RFQ 파일 다운로드 오류:', error);
+
+ // 실패 감사 로그 기록
+ try {
+ const rawPath = request.nextUrl.searchParams.get("path");
+ if (rawPath) {
+ const normalizedPath = normalize(rawPath.replace(/^\/+/, ""));
+ const [dbRecord] = await db
+ .select({
+ id: rfqAttachments.id,
+ fileName: rfqAttachments.fileName,
+ filePath: rfqAttachments.filePath,
+ })
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.filePath, normalizedPath));
+
+ if (dbRecord) {
+ await createFileDownloadLog({ fileId: dbRecord.id, success: false, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } });
+
+ }
+ }
+ } catch (logError) {
+ console.error('감사 로그 기록 실패:', logError);
+ }
+
+ // 에러 정보 최소화 (정보 노출 방지)
return NextResponse.json(
- {
- error: 'Failed to download file',
- details: String(error)
- },
+ { error: 'Internal server error' },
{ status: 500 }
);
}