summaryrefslogtreecommitdiff
path: root/app/api
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-01 19:52:06 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-01 19:52:06 +0900
commit44b74ff4170090673b6eeacd8c528e0abf47b7aa (patch)
tree3f3824b4e2cb24536c1677188b4cae5b8909d3da /app/api
parent4953e770929b82ef77da074f77071ebd0f428529 (diff)
(김준회) deprecated code 정리
Diffstat (limited to 'app/api')
-rw-r--r--app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts145
-rw-r--r--app/api/rfq-attachments/download/route.ts474
-rw-r--r--app/api/tbe-download/route.ts417
-rw-r--r--app/api/vendor-responses/update-comment/route.ts62
-rw-r--r--app/api/vendor-responses/update/route.ts118
-rw-r--r--app/api/vendor-responses/upload/route.ts105
-rw-r--r--app/api/vendor-responses/waive/route.ts69
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