diff options
Diffstat (limited to 'app/api')
| -rw-r--r-- | app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts | 145 | ||||
| -rw-r--r-- | app/api/rfq-attachments/download/route.ts | 474 | ||||
| -rw-r--r-- | app/api/tbe-download/route.ts | 417 | ||||
| -rw-r--r-- | app/api/vendor-responses/update-comment/route.ts | 62 | ||||
| -rw-r--r-- | app/api/vendor-responses/update/route.ts | 118 | ||||
| -rw-r--r-- | app/api/vendor-responses/upload/route.ts | 105 | ||||
| -rw-r--r-- | app/api/vendor-responses/waive/route.ts | 69 |
7 files changed, 0 insertions, 1390 deletions
diff --git a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts deleted file mode 100644 index 51430118..00000000 --- a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts +++ /dev/null @@ -1,145 +0,0 @@ -// app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts -import { NextRequest, NextResponse } from "next/server" - -import db from '@/db/db'; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" - -import { procurementRfqComments, procurementRfqAttachments } from "@/db/schema" -import { revalidateTag } from "next/cache" - -// 파일 저장을 위한 유틸리티 -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' -import crypto from 'crypto' - -/** - * 코멘트 생성 API 엔드포인트 - */ -export async function POST( - request: NextRequest, - { params }: { params: { rfqId: string; vendorId: string } } -) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json( - { success: false, message: "인증이 필요합니다" }, - { status: 401 } - ) - } - - const rfqId = parseInt(params.rfqId) - const vendorId = parseInt(params.vendorId) - - // 유효성 검사 - if (isNaN(rfqId) || isNaN(vendorId)) { - return NextResponse.json( - { success: false, message: "유효하지 않은 매개변수입니다" }, - { status: 400 } - ) - } - - // FormData 파싱 - const formData = await request.formData() - const content = formData.get("content") as string - const isVendorComment = formData.get("isVendorComment") === "true" - const files = formData.getAll("attachments") as File[] - - if (!content && files.length === 0) { - return NextResponse.json( - { success: false, message: "내용이나 첨부파일이 필요합니다" }, - { status: 400 } - ) - } - - // 코멘트 생성 - const [comment] = await db - .insert(procurementRfqComments) - .values({ - rfqId, - vendorId, - userId: parseInt(session.user.id), - content, - isVendorComment, - isRead: !isVendorComment, // 본인 메시지는 읽음 처리 - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - // 첨부파일 처리 - const attachments = [] - if (files.length > 0) { - // 디렉토리 생성 - const uploadDir = join(process.cwd(), "public", `rfq-${rfqId}`, `vendor-${vendorId}`, `comment-${comment.id}`) - await mkdir(uploadDir, { recursive: true }) - - // 각 파일 저장 - for (const file of files) { - const buffer = Buffer.from(await file.arrayBuffer()) - const filename = `${Date.now()}-${crypto.randomBytes(8).toString("hex")}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}` - const filePath = join(uploadDir, filename) - - // 파일 쓰기 - await writeFile(filePath, buffer) - - // DB에 첨부파일 정보 저장 - const [attachment] = await db - .insert(procurementRfqAttachments) - .values({ - rfqId, - commentId: comment.id, - fileName: file.name, - fileSize: file.size, - fileType: file.type, - filePath: `/rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}/${filename}`, - isVendorUpload: isVendorComment, - uploadedBy: parseInt(session.user.id), - vendorId, - uploadedAt: new Date(), - }) - .returning() - - attachments.push({ - id: attachment.id, - fileName: attachment.fileName, - fileSize: attachment.fileSize, - fileType: attachment.fileType, - filePath: attachment.filePath, - uploadedAt: attachment.uploadedAt - }) - } - } - - // 캐시 무효화 - revalidateTag(`rfq-${rfqId}-comments`) - - // 응답 데이터 구성 - const responseData = { - id: comment.id, - rfqId: comment.rfqId, - vendorId: comment.vendorId, - userId: comment.userId, - content: comment.content, - isVendorComment: comment.isVendorComment, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - userName: session.user.name, - attachments, - isRead: comment.isRead - } - - return NextResponse.json({ - success: true, - data: { comment: responseData } - }) - } catch (error) { - console.error("코멘트 생성 오류:", error) - return NextResponse.json( - { success: false, message: "코멘트 생성 중 오류가 발생했습니다" }, - { status: 500 } - ) - } -}
\ No newline at end of file diff --git a/app/api/rfq-attachments/download/route.ts b/app/api/rfq-attachments/download/route.ts deleted file mode 100644 index 5a07bc0b..00000000 --- a/app/api/rfq-attachments/download/route.ts +++ /dev/null @@ -1,474 +0,0 @@ -// app/api/rfq-attachments/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 { bRfqAttachmentRevisions, vendorResponseAttachmentsB } from '@/db/schema'; -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'), - type: z.enum(['client', 'vendor']).optional(), - revisionId: z.string().optional(), - responseFileId: z.string().optional(), -}); - -// 파일 정보 타입 -interface FileRecord { - id: number; - fileName: string; - originalFileName?: 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"), - type: request.nextUrl.searchParams.get("type"), - revisionId: request.nextUrl.searchParams.get("revisionId"), - responseFileId: request.nextUrl.searchParams.get("responseFileId"), - }; - - const validatedParams = downloadRequestSchema.parse(searchParams); - const { path, type, revisionId, responseFileId } = 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에서 파일 정보 조회 - let dbRecord: FileRecord | null = null; - - if (type === "client" && revisionId) { - // 발주처 첨부파일 리비전 - const [record] = await db - .select({ - id: bRfqAttachmentRevisions.id, - fileName: bRfqAttachmentRevisions.fileName, - originalFileName: bRfqAttachmentRevisions.originalFileName, - filePath: bRfqAttachmentRevisions.filePath, - fileSize: bRfqAttachmentRevisions.fileSize, - fileType: bRfqAttachmentRevisions.fileType, - }) - .from(bRfqAttachmentRevisions) - .where(eq(bRfqAttachmentRevisions.id, Number(revisionId))); - - dbRecord = record; - - } else if (type === "vendor" && responseFileId) { - // 벤더 응답 파일 - const [record] = await db - .select({ - id: vendorResponseAttachmentsB.id, - fileName: vendorResponseAttachmentsB.fileName, - originalFileName: vendorResponseAttachmentsB.originalFileName, - filePath: vendorResponseAttachmentsB.filePath, - fileSize: vendorResponseAttachmentsB.fileSize, - fileType: vendorResponseAttachmentsB.fileType, - }) - .from(vendorResponseAttachmentsB) - .where(eq(vendorResponseAttachmentsB.id, Number(responseFileId))); - - dbRecord = record; - - } else { - // filePath로 직접 검색 (fallback) - 정규화된 경로로 검색 - const [clientRecord] = await db - .select({ - id: bRfqAttachmentRevisions.id, - fileName: bRfqAttachmentRevisions.fileName, - originalFileName: bRfqAttachmentRevisions.originalFileName, - filePath: bRfqAttachmentRevisions.filePath, - fileSize: bRfqAttachmentRevisions.fileSize, - fileType: bRfqAttachmentRevisions.fileType, - }) - .from(bRfqAttachmentRevisions) - .where(eq(bRfqAttachmentRevisions.filePath, normalizedPath)); - - if (clientRecord) { - dbRecord = clientRecord; - } else { - // 벤더 파일에서도 검색 - const [vendorRecord] = await db - .select({ - id: vendorResponseAttachmentsB.id, - fileName: vendorResponseAttachmentsB.fileName, - originalFileName: vendorResponseAttachmentsB.originalFileName, - filePath: vendorResponseAttachmentsB.filePath, - fileSize: vendorResponseAttachmentsB.fileSize, - fileType: vendorResponseAttachmentsB.fileType, - }) - .from(vendorResponseAttachmentsB) - .where(eq(vendorResponseAttachmentsB.filePath, normalizedPath)); - - dbRecord = vendorRecord; - } - } - - // 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.originalFileName || 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) { - // 조용히 다음 디렉터리 시도 - } - } - - if (!actualPath || !baseDir) { - console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { - normalizedPath, - userId: session.user.id, - requestedPath: path - }); - - // 실패 로그 기록 - 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" }, - { 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("✅ 파일 다운로드 성공:", { - 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('❌ RFQ 첨부파일 다운로드 오류:', { - 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 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 diff --git a/app/api/vendor-responses/update-comment/route.ts b/app/api/vendor-responses/update-comment/route.ts deleted file mode 100644 index f1e4c487..00000000 --- a/app/api/vendor-responses/update-comment/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -// app/api/vendor-responses/update-comment/route.ts -import { NextRequest, NextResponse } from "next/server"; -import db from "@/db/db"; -import { vendorAttachmentResponses } from "@/db/schema"; - -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { eq } from "drizzle-orm"; - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const body = await request.json(); - const { responseId, responseComment, vendorComment } = body; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - // 코멘트만 업데이트 - const [updatedResponse] = await db - .update(vendorAttachmentResponses) - .set({ - responseComment, - vendorComment, - updatedAt: new Date(), - updatedBy:Number(session?.user.id) - }) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .returning(); - - if (!updatedResponse) { - return NextResponse.json( - { message: "응답을 찾을 수 없습니다." }, - { status: 404 } - ); - } - - return NextResponse.json({ - message: "코멘트가 성공적으로 업데이트되었습니다.", - response: updatedResponse, - }); - - } catch (error) { - console.error("Comment update error:", error); - return NextResponse.json( - { message: "코멘트 업데이트 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/vendor-responses/update/route.ts b/app/api/vendor-responses/update/route.ts deleted file mode 100644 index cf7e551c..00000000 --- a/app/api/vendor-responses/update/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -// app/api/vendor-responses/update/route.ts -import { NextRequest, NextResponse } from "next/server"; -import db from "@/db/db"; -import { vendorAttachmentResponses } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" - -// 리비전 번호를 증가시키는 헬퍼 함수 -function getNextRevision(currentRevision?: string): string { - if (!currentRevision) { - return "Rev.0"; // 첫 번째 응답 - } - - // "Rev.1" -> 1, "Rev.2" -> 2 형태로 숫자 추출 - const match = currentRevision.match(/Rev\.(\d+)/); - if (match) { - const currentNumber = parseInt(match[1]); - return `Rev.${currentNumber + 1}`; - } - - // 형식이 다르면 기본값 반환 - return "Rev.0"; -} - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const body = await request.json(); - const { - responseId, - responseStatus, - responseComment, - vendorComment, - respondedAt, - } = body; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - // 1. 기존 응답 정보 조회 (현재 respondedRevision 확인) - const existingResponse = await db - .select() - .from(vendorAttachmentResponses) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .limit(1); - - if (!existingResponse || existingResponse.length === 0) { - return NextResponse.json( - { message: "응답을 찾을 수 없습니다." }, - { status: 404 } - ); - } - - const currentResponse = existingResponse[0]; - - // 2. 벤더 응답 리비전 결정 - let nextRespondedRevision: string; - - - if (responseStatus === "RESPONDED") { - - // 첫 응답이거나 수정 요청 후 재응답인 경우 리비전 증가 - nextRespondedRevision = getNextRevision(currentResponse.respondedRevision); - - } else { - // WAIVED 등 다른 상태는 기존 리비전 유지 - nextRespondedRevision = currentResponse.respondedRevision || ""; - } - - // 3. vendor response 업데이트 - const [updatedResponse] = await db - .update(vendorAttachmentResponses) - .set({ - responseStatus, - respondedRevision: nextRespondedRevision, - responseComment, - vendorComment, - respondedAt: respondedAt ? new Date(respondedAt) : null, - updatedAt: new Date(), - updatedBy:Number(session?.user.id) - }) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .returning(); - - if (!updatedResponse) { - return NextResponse.json( - { message: "응답 업데이트에 실패했습니다." }, - { status: 500 } - ); - } - - return NextResponse.json({ - message: "응답이 성공적으로 업데이트되었습니다.", - response: updatedResponse, - newRevision: nextRespondedRevision, // 새로운 리비전 정보 반환 - }); - - } catch (error) { - console.error("Response update error:", error); - return NextResponse.json( - { message: "응답 업데이트 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/vendor-responses/upload/route.ts b/app/api/vendor-responses/upload/route.ts deleted file mode 100644 index 111e4bd4..00000000 --- a/app/api/vendor-responses/upload/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -// app/api/vendor-response-attachments/upload/route.ts -import { NextRequest, NextResponse } from "next/server"; -import { writeFile, mkdir } from "fs/promises"; -import { existsSync } from "fs"; -import path from "path"; -import db from "@/db/db"; -import { vendorResponseAttachmentsB } from "@/db/schema"; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const formData = await request.formData(); - const responseId = formData.get("responseId") as string; - const file = formData.get("file") as File; - const description = formData.get("description") as string; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - if (!file) { - return NextResponse.json( - { message: "파일이 선택되지 않았습니다." }, - { status: 400 } - ); - } - - // 파일 크기 검증 (10MB) - if (file.size > 10 * 1024 * 1024) { - return NextResponse.json( - { message: "파일이 너무 큽니다. (최대 10MB)" }, - { status: 400 } - ); - } - - // 업로드 디렉토리 생성 - const uploadDir = path.join( - process.cwd(), - "public", - "uploads", - "vendor-responses", - responseId - ); - - if (!existsSync(uploadDir)) { - await mkdir(uploadDir, { recursive: true }); - } - - // 고유한 파일명 생성 - const timestamp = Date.now(); - const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); - const fileName = `${timestamp}_${sanitizedName}`; - const filePath = `/uploads/vendor-responses/${responseId}/${fileName}`; - const fullPath = path.join(uploadDir, fileName); - - // 파일 저장 - const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(fullPath, buffer); - - // DB에 파일 정보 저장 - const [insertedFile] = await db - .insert(vendorResponseAttachmentsB) - .values({ - vendorResponseId: parseInt(responseId), - fileName, - originalFileName: file.name, - filePath, - fileSize: file.size, - fileType: file.type || path.extname(file.name).slice(1), - description: description || null, - uploadedBy: parseInt(session.user.id), - }) - .returning(); - - return NextResponse.json({ - id: insertedFile.id, - fileName, - originalFileName: file.name, - filePath, - fileSize: file.size, - fileType: file.type || path.extname(file.name).slice(1), - message: "파일이 성공적으로 업로드되었습니다.", - }); - - } catch (error) { - console.error("File upload error:", error); - return NextResponse.json( - { message: "파일 업로드 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file diff --git a/app/api/vendor-responses/waive/route.ts b/app/api/vendor-responses/waive/route.ts deleted file mode 100644 index e732e8d2..00000000 --- a/app/api/vendor-responses/waive/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -// app/api/vendor-responses/waive/route.ts -import { NextRequest, NextResponse } from "next/server"; -import db from "@/db/db"; -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { eq } from "drizzle-orm"; -import { vendorAttachmentResponses } from "@/db/schema"; - -export async function POST(request: NextRequest) { - try { - // 인증 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { message: "인증이 필요합니다." }, - { status: 401 } - ); - } - - const body = await request.json(); - const { responseId, responseComment, vendorComment } = body; - - if (!responseId) { - return NextResponse.json( - { message: "응답 ID가 필요합니다." }, - { status: 400 } - ); - } - - if (!responseComment) { - return NextResponse.json( - { message: "포기 사유를 입력해주세요." }, - { status: 400 } - ); - } - - // vendor response를 WAIVED 상태로 업데이트 - const [updatedResponse] = await db - .update(vendorAttachmentResponses) - .set({ - responseStatus: "WAIVED", - responseComment, - vendorComment, - respondedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(vendorAttachmentResponses.id, parseInt(responseId))) - .returning(); - - if (!updatedResponse) { - return NextResponse.json( - { message: "응답을 찾을 수 없습니다." }, - { status: 404 } - ); - } - - return NextResponse.json({ - message: "응답이 성공적으로 포기 처리되었습니다.", - response: updatedResponse, - }); - - } catch (error) { - console.error("Waive response error:", error); - return NextResponse.json( - { message: "응답 포기 처리 중 오류가 발생했습니다." }, - { status: 500 } - ); - } -}
\ No newline at end of file |
