diff options
Diffstat (limited to 'app/api/tbe-download/route.ts')
| -rw-r--r-- | app/api/tbe-download/route.ts | 417 |
1 files changed, 0 insertions, 417 deletions
diff --git a/app/api/tbe-download/route.ts b/app/api/tbe-download/route.ts deleted file mode 100644 index 93eb62db..00000000 --- a/app/api/tbe-download/route.ts +++ /dev/null @@ -1,417 +0,0 @@ -// app/api/tbe-download/route.ts -import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants, stat } from 'fs/promises'; -import { join, normalize, resolve } from 'path'; -import db from '@/db/db'; -import { rfqAttachments, vendorResponseAttachments } from '@/db/schema/rfq'; -import { eq } from 'drizzle-orm'; -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'; - -// 허용된 파일 확장자 -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' -]); - -// 최대 파일 크기 (50MB) -const MAX_FILE_SIZE = 50 * 1024 * 1024; - -// 다운로드 요청 검증 스키마 -const downloadRequestSchema = z.object({ - path: z.string().min(1, 'File path is required'), -}); - -// 파일 정보 타입 -interface FileRecord { - id: number; - fileName: string; - filePath: string; - fileSize?: number; - fileType?: string; -} - - -// 강화된 파일 경로 검증 함수 -function validateFilePath(filePath: string): boolean { - // null, undefined, 빈 문자열 체크 - if (!filePath || typeof filePath !== 'string') { - return false; - } - - // 위험한 패턴 체크 - const dangerousPatterns = [ - /\.\./, // 상위 디렉토리 접근 - /\/\//, // 이중 슬래시 - /[<>:"'|?*]/, // 특수문자 - /[\x00-\x1f]/, // 제어문자 - /\\+/ // 백슬래시 - ]; - - if (dangerousPatterns.some(pattern => pattern.test(filePath))) { - return false; - } - - // 시스템 파일 접근 방지 - const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; - for (const dangerousPath of dangerousPaths) { - if (filePath.toLowerCase().startsWith(dangerousPath)) { - return false; - } - } - - return true; -} - -// 파일 확장자 검증 -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) { - const startTime = Date.now(); - const requestInfo = getRequestInfo(request); - let fileRecord: FileRecord | null = null; - - try { - // Rate limiting 체크 - const limiterResult = await rateLimit(request); - if (!limiterResult.success) { - console.warn('🚨 Rate limit 초과:', { - ip: requestInfo.ip, - userAgent: requestInfo.userAgent - }); - - 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") - }); - - return NextResponse.json( - { error: "Unauthorized" }, - { status: 401 } - ); - } - - // 파라미터 검증 - const searchParams = { - path: request.nextUrl.searchParams.get("path"), - }; - - const validatedParams = downloadRequestSchema.parse(searchParams); - const { path } = validatedParams; - - // 파일 경로 보안 검증 - if (!validateFilePath(path)) { - console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, { - userId: session.user.id, - ip: requestInfo.ip, - userAgent: requestInfo.userAgent - }); - - return NextResponse.json( - { error: "Invalid file path" }, - { status: 400 } - ); - } - - // 경로 정규화 - const normalizedPath = normalize(path.replace(/^\/+/, "")); - - // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색) - const [dbRecord] = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - fileType: vendorResponseAttachments.fileType, - }) - .from(vendorResponseAttachments) - .where(eq(vendorResponseAttachments.filePath, normalizedPath)); - - // DB에서 파일 정보를 찾지 못한 경우 - if (!dbRecord) { - console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", { - path, - normalizedPath, - userId: session.user.id, - ip: requestInfo.ip - }); - - return NextResponse.json( - { error: "File not found in database" }, - { status: 404 } - ); - } - - fileRecord = dbRecord; - - // 파일명 설정 - const fileName = dbRecord.fileName; - - // 파일 확장자 검증 - if (!validateFileExtension(fileName)) { - console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { - userId: session.user.id, - ip: requestInfo.ip - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - 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", "uploads", "storage"]; - 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); - - // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 - if (!testPath.startsWith(baseDir)) { - continue; - } - - try { - await access(testPath, constants.R_OK); - actualPath = testPath; - console.log("✅ 파일 발견:", testPath); - break; - } catch (err) { - console.log("❌ 경로에 파일 없음:", testPath); - } - } - - if (!actualPath || !baseDir) { - console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { - normalizedPath, - userId: session.user.id, - requestedPath: path, - triedDirs: allowedDirs - }); - - // 실패 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: false, - errorMessage: 'File not found on server', - requestId: requestInfo.requestId, - fileInfo: { - fileName, - filePath: path, - fileSize: dbRecord.fileSize || 0, - } - }); - - return NextResponse.json( - { - error: "File not found on server", - details: { - path: path, - fileName: fileName, - } - }, - { 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: dbRecord.id, - 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 fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - let contentType = dbRecord.fileType || 'application/octet-stream'; - - // 확장자에 따른 MIME 타입 매핑 (fallback) - 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 safeFileName = sanitizeFileName(fileName); - - // 보안 헤더와 다운로드용 헤더 설정 - const headers = new Headers(); - headers.set('Content-Type', contentType); - headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`); - headers.set('Content-Length', fileBuffer.length.toString()); - - // 보안 헤더 - headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); - headers.set('Pragma', 'no-cache'); - headers.set('Expires', '0'); - headers.set('X-Content-Type-Options', 'nosniff'); - headers.set('X-Frame-Options', 'DENY'); - headers.set('X-XSS-Protection', '1; mode=block'); - headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - - // 성공 로그 기록 - await createFileDownloadLog({ - fileId: dbRecord.id, - success: true, - requestId: requestInfo.requestId, - fileInfo: { - fileName: safeFileName, - filePath: path, - fileSize: fileBuffer.length, - } - }); - - console.log("✅ TBE 파일 다운로드 성공:", { - fileName: safeFileName, - contentType, - size: fileBuffer.length, - actualPath, - userId: session.user.id, - ip: requestInfo.ip, - downloadDurationMs: Date.now() - startTime - }); - - return new NextResponse(fileBuffer, { - status: 200, - headers, - }); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - console.error('❌ TBE 파일 다운로드 오류:', { - error: errorMessage, - userId: (await getServerSession(authOptions))?.user?.id, - ip: requestInfo.ip, - path: request.nextUrl.searchParams.get("path"), - downloadDurationMs: Date.now() - startTime - }); - - // 에러 로그 기록 - if (fileRecord?.id) { - try { - await createFileDownloadLog({ - fileId: fileRecord.id, - success: false, - errorMessage, - requestId: requestInfo.requestId, - fileInfo: { - fileName: fileRecord.fileName || 'unknown', - filePath: request.nextUrl.searchParams.get("path") || '', - fileSize: fileRecord.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 |
