summaryrefslogtreecommitdiff
path: root/app/api/tech-sales-rfq-download
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-18 07:52:02 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-18 07:52:02 +0000
commit48a2255bfc45ffcfb0b39ffefdd57cbacf8b36df (patch)
tree0c88b7c126138233875e8d372a4e999e49c38a62 /app/api/tech-sales-rfq-download
parent2ef02e27dbe639876fa3b90c30307dda183545ec (diff)
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'app/api/tech-sales-rfq-download')
-rw-r--r--app/api/tech-sales-rfq-download/route.ts436
1 files changed, 365 insertions, 71 deletions
diff --git a/app/api/tech-sales-rfq-download/route.ts b/app/api/tech-sales-rfq-download/route.ts
index b9dd14d1..e0219787 100644
--- a/app/api/tech-sales-rfq-download/route.ts
+++ b/app/api/tech-sales-rfq-download/route.ts
@@ -1,85 +1,379 @@
-import { NextRequest } from "next/server"
-import { join } from "path"
-import { readFile } from "fs/promises"
+import { NextRequest, NextResponse } from "next/server";
+import { join, normalize, resolve } from "path";
+import { readFile, access, constants, stat } from "fs/promises";
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { createFileDownloadLog } from '@/lib/file-download-log/service';
+import rateLimit from '@/lib/rate-limit';
+import { z } from 'zod';
+import { getRequestInfo } from "@/lib/network/get-client-ip";
-export async function GET(request: NextRequest) {
- try {
- const searchParams = request.nextUrl.searchParams
- const filePath = searchParams.get("path")
+// 허용된 파일 확장자
+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'
+]);
- if (!filePath) {
- return new Response("File path is required", { status: 400 })
- }
+// 최대 파일 크기 (50MB)
+const MAX_FILE_SIZE = 50 * 1024 * 1024;
+
+// 다운로드 요청 검증 스키마
+const downloadRequestSchema = z.object({
+ path: z.string()
+ .min(1, 'File path is required')
+ .refine(path => path.startsWith("/techsales-rfq/"), {
+ message: 'Invalid file path - must start with /techsales-rfq/'
+ })
+ .refine(path => !path.includes(".."), {
+ message: 'Invalid file path - directory traversal not allowed'
+ }),
+});
+
+
+// 강화된 파일 경로 검증
+function validateFilePath(filePath: string): boolean {
+ // null, undefined, 빈 문자열 체크
+ if (!filePath || typeof filePath !== 'string') {
+ return false;
+ }
+
+ // 위험한 패턴 체크
+ const dangerousPatterns = [
+ /\.\./, // 상위 디렉토리 접근
+ /\/\//, // 이중 슬래시
+ /[<>:"'|?*]/, // 특수문자
+ /[\x00-\x1f]/, // 제어문자
+ /\\+/ // 백슬래시
+ ];
- // 보안: 경로 조작 방지
- if (filePath.includes("..") || !filePath.startsWith("/techsales-rfq/")) {
- return new Response("Invalid file path", { status: 400 })
+ if (dangerousPatterns.some(pattern => pattern.test(filePath))) {
+ return false;
+ }
+
+ // techsales-rfq 경로만 허용
+ if (!filePath.startsWith('/techsales-rfq/')) {
+ return false;
+ }
+
+ // 시스템 파일 접근 방지
+ const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home'];
+ for (const dangerousPath of dangerousPaths) {
+ if (filePath.toLowerCase().includes(dangerousPath)) {
+ return false;
}
+ }
+
+ return true;
+}
- // 파일 경로 구성 (public 폴더 기준)
- const fullPath = join(process.cwd(), "public", filePath)
+// 파일 확장자 검증
+function validateFileExtension(fileName: string): boolean {
+ const extension = fileName.split('.').pop()?.toLowerCase() || '';
+ return ALLOWED_EXTENSIONS.has(extension);
+}
- try {
- // 파일 읽기
- const fileBuffer = await readFile(fullPath)
+// 안전한 파일명 생성
+function sanitizeFileName(fileName: string): string {
+ return fileName
+ .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거
+ .replace(/\s+/g, '_') // 공백을 언더스코어로
+ .substring(0, 255); // 파일명 길이 제한
+}
+
+// MIME 타입 결정
+function getMimeType(fileName: string): string {
+ const ext = fileName.split(".").pop()?.toLowerCase();
+
+ 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',
+ };
+
+ return mimeTypes[ext || ''] || 'application/octet-stream';
+}
+
+export async function GET(request: NextRequest) {
+ const startTime = Date.now();
+ const requestInfo = getRequestInfo(request);
+
+ try {
+ // Rate limiting 체크
+ const limiterResult = await rateLimit(request);
+ if (!limiterResult.success) {
+ console.warn('🚨 Rate limit 초과:', {
+ ip: requestInfo.ip,
+ userAgent: requestInfo.userAgent
+ });
- // 파일명 추출
- const fileName = filePath.split("/").pop() || "download"
+ return NextResponse.json(
+ { error: "Too many requests" },
+ { status: 429 }
+ );
+ }
+
+ // 세션 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ console.warn('🚨 인증되지 않은 다운로드 시도:', {
+ ip: requestInfo.ip,
+ userAgent: requestInfo.userAgent,
+ path: request.nextUrl.searchParams.get("path")
+ });
- // MIME 타입 결정
- const ext = fileName.split(".").pop()?.toLowerCase()
- let contentType = "application/octet-stream"
+ return NextResponse.json(
+ { error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // 파라미터 검증
+ const searchParams = request.nextUrl.searchParams;
+ const filePath = searchParams.get("path");
+
+ const validatedParams = downloadRequestSchema.parse({ path: filePath });
+ const { path } = validatedParams;
+
+ // 추가 보안 검증
+ if (!validateFilePath(path)) {
+ console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, {
+ userId: session.user.id,
+ ip: requestInfo.ip,
+ userAgent: requestInfo.userAgent
+ });
- switch (ext) {
- case "pdf":
- contentType = "application/pdf"
- break
- case "doc":
- contentType = "application/msword"
- break
- case "docx":
- contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- break
- case "xls":
- contentType = "application/vnd.ms-excel"
- break
- case "xlsx":
- contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
- break
- case "jpg":
- case "jpeg":
- contentType = "image/jpeg"
- break
- case "png":
- contentType = "image/png"
- break
- case "gif":
- contentType = "image/gif"
- break
- case "txt":
- contentType = "text/plain"
- break
- case "zip":
- contentType = "application/zip"
- break
- default:
- contentType = "application/octet-stream"
+ return NextResponse.json(
+ { error: "Invalid file path" },
+ { status: 400 }
+ );
+ }
+
+ // 경로 정규화
+ const normalizedPath = normalize(path);
+
+ // 파일명 추출 및 검증
+ const fileName = path.split("/").pop() || "download";
+
+ // 파일 확장자 검증
+ if (!validateFileExtension(fileName)) {
+ console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, {
+ userId: session.user.id,
+ ip: requestInfo.ip
+ });
+
+ // 실패 로그 기록
+ await createFileDownloadLog({
+ fileId: 0, // techsales 파일은 별도 ID가 없으므로 0으로 기록
+ success: false,
+ errorMessage: 'File type not allowed',
+ requestId: requestInfo.requestId,
+ fileInfo: {
+ fileName,
+ filePath: path,
+ fileSize: 0,
+ }
+ });
+
+ return NextResponse.json(
+ { error: "File type not allowed" },
+ { status: 403 }
+ );
+ }
+
+ // 안전한 파일 경로 구성
+ const allowedDirs = ["public"];
+ let actualPath: string | null = null;
+ let baseDir: string | null = null;
+
+ // 허용된 디렉터리에서 파일 찾기
+ for (const dir of allowedDirs) {
+ baseDir = resolve(process.cwd(), dir);
+ const testPath = resolve(baseDir, normalizedPath.substring(1)); // leading slash 제거
+
+ // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단
+ if (!testPath.startsWith(baseDir)) {
+ continue;
+ }
+
+ try {
+ await access(testPath, constants.R_OK);
+ actualPath = testPath;
+ console.log("✅ 파일 발견:", testPath);
+ break;
+ } catch (err) {
+ // 조용히 다음 경로 시도
}
+ }
+
+ if (!actualPath || !baseDir) {
+ console.error("❌ 파일 접근 불가:", {
+ normalizedPath,
+ userId: session.user.id,
+ requestedPath: path,
+ triedDirs: allowedDirs
+ });
+
+ // 실패 로그 기록
+ await createFileDownloadLog({
+ fileId: 0,
+ success: false,
+ errorMessage: 'File not found on server',
+ requestId: requestInfo.requestId,
+ fileInfo: {
+ fileName,
+ filePath: path,
+ fileSize: 0,
+ }
+ });
- // 응답 헤더 설정
- const headers = new Headers({
- "Content-Type": contentType,
- "Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
- "Content-Length": fileBuffer.length.toString(),
- })
-
- return new Response(fileBuffer, { headers })
- } catch (fileError) {
- console.error("File read error:", fileError)
- return new Response("File not found", { status: 404 })
+ return NextResponse.json(
+ { error: "File not found" },
+ { status: 404 }
+ );
}
+
+ // 파일 크기 확인
+ const stats = await stat(actualPath);
+ if (stats.size > MAX_FILE_SIZE) {
+ console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, {
+ userId: session.user.id,
+ ip: requestInfo.ip
+ });
+
+ // 실패 로그 기록
+ await createFileDownloadLog({
+ fileId: 0,
+ success: false,
+ errorMessage: 'File too large',
+ requestId: requestInfo.requestId,
+ fileInfo: {
+ fileName,
+ filePath: path,
+ fileSize: stats.size,
+ }
+ });
+
+ return NextResponse.json(
+ { error: "File too large" },
+ { status: 413 }
+ );
+ }
+
+ // 파일 읽기
+ const fileBuffer = await readFile(actualPath);
+
+ // MIME 타입 결정
+ const contentType = getMimeType(fileName);
+
+ // 안전한 파일명 생성 (특수문자 처리)
+ const safeFileName = sanitizeFileName(fileName);
+
+ // 보안 헤더와 다운로드용 헤더 설정
+ const headers = new Headers({
+ 'Content-Type': contentType,
+ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`,
+ 'Content-Length': fileBuffer.length.toString(),
+
+ // 보안 헤더
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0',
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ 'X-XSS-Protection': '1; mode=block',
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
+ });
+
+ // 성공 로그 기록
+ await createFileDownloadLog({
+ fileId: 0, // techsales 파일용 ID
+ success: true,
+ requestId: requestInfo.requestId,
+ fileInfo: {
+ fileName: safeFileName,
+ filePath: path,
+ fileSize: fileBuffer.length,
+ }
+ });
+
+ console.log("✅ 파일 다운로드 성공:", {
+ fileName: safeFileName,
+ contentType,
+ size: fileBuffer.length,
+ actualPath,
+ userId: session.user.id,
+ ip: requestInfo.ip,
+ downloadDurationMs: Date.now() - startTime
+ });
+
+ return new Response(fileBuffer, { headers });
+
} catch (error) {
- console.error("Download error:", error)
- return new Response("Internal server error", { status: 500 })
+ const errorMessage = error instanceof Error ? error.message : String(error);
+
+ console.error('❌ Techsales 파일 다운로드 오류:', {
+ error: errorMessage,
+ userId: (await getServerSession(authOptions))?.user?.id,
+ ip: requestInfo.ip,
+ path: request.nextUrl.searchParams.get("path"),
+ downloadDurationMs: Date.now() - startTime
+ });
+
+ // 에러 로그 기록
+ try {
+ const filePath = request.nextUrl.searchParams.get("path") || '';
+ const fileName = filePath.split("/").pop() || 'unknown';
+
+ await createFileDownloadLog({
+ fileId: 0,
+ success: false,
+ errorMessage,
+ requestId: requestInfo.requestId,
+ fileInfo: {
+ fileName,
+ filePath,
+ fileSize: 0,
+ }
+ });
+ } catch (logError) {
+ console.error('로그 기록 실패:', logError);
+ }
+
+ // Zod 검증 에러 처리
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ {
+ error: 'Invalid request parameters',
+ details: error.errors.map(e => e.message).join(', ')
+ },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ {
+ error: 'Internal server error',
+ details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
+ },
+ { status: 500 }
+ );
}
-} \ No newline at end of file
+} \ No newline at end of file