summaryrefslogtreecommitdiff
path: root/app
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
parent2ef02e27dbe639876fa3b90c30307dda183545ec (diff)
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/partners/(partners)/system/layout.tsx6
-rw-r--r--app/[lng]/qna/layout.tsx18
-rw-r--r--app/[lng]/qna/page.tsx8
-rw-r--r--app/api/client-logs/route.ts259
-rw-r--r--app/api/notifications/[id]/deleate/route.ts34
-rw-r--r--app/api/notifications/[id]/read/route.ts34
-rw-r--r--app/api/notifications/read-all/route.ts26
-rw-r--r--app/api/notifications/route.ts37
-rw-r--r--app/api/notifications/stats/route.ts28
-rw-r--r--app/api/notifications/stream/route.ts54
-rw-r--r--app/api/rfq-attachments/download/route.ts371
-rw-r--r--app/api/rfq-download/route.ts262
-rw-r--r--app/api/tbe-download/route.ts432
-rw-r--r--app/api/tech-sales-rfq-download/route.ts436
14 files changed, 1708 insertions, 297 deletions
diff --git a/app/[lng]/partners/(partners)/system/layout.tsx b/app/[lng]/partners/(partners)/system/layout.tsx
index 54330176..66f0f9cc 100644
--- a/app/[lng]/partners/(partners)/system/layout.tsx
+++ b/app/[lng]/partners/(partners)/system/layout.tsx
@@ -29,15 +29,15 @@ export default async function SettingsLayout({
{
title: "사용자",
- href: `/${lng}/evcp/system`,
+ href: `/${lng}/partners/system`,
},
{
title: "Roles",
- href: `/${lng}/evcp/system/roles`,
+ href: `/${lng}/partners/system/roles`,
},
{
title: "권한 통제",
- href: `/${lng}/evcp/system/permissions`,
+ href: `/${lng}/partners/system/permissions`,
},
]
diff --git a/app/[lng]/qna/layout.tsx b/app/[lng]/qna/layout.tsx
deleted file mode 100644
index 87651e92..00000000
--- a/app/[lng]/qna/layout.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ReactNode } from 'react';
-
-
-export default function EvcpLayout({ children }: { children: ReactNode }) {
- return (
- <div className="relative flex min-h-svh flex-col bg-background">
- <main className="flex flex-1 flex-col">
- <div className='container-wrapper'>
- <div className="container flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
- {children}
- </div>
-
- </div>
-
- </main>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/qna/page.tsx b/app/[lng]/qna/page.tsx
deleted file mode 100644
index 10280464..00000000
--- a/app/[lng]/qna/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-
-export default function Pages() {
- return (
- <>
- qna
- </>
- )
- } \ No newline at end of file
diff --git a/app/api/client-logs/route.ts b/app/api/client-logs/route.ts
new file mode 100644
index 00000000..42e7db63
--- /dev/null
+++ b/app/api/client-logs/route.ts
@@ -0,0 +1,259 @@
+// app/api/client-logs/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+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 clientLogSchema = z.object({
+ event: z.enum(['download_attempt', 'download_success', 'download_error', 'security_violation']),
+ data: z.object({
+ // 파일 관련 정보
+ filePath: z.string(),
+ fileName: z.string(),
+ fileExtension: z.string().optional(),
+ fileSize: z.number().optional(),
+ duration: z.number().optional(),
+
+ // 액션 관련 정보
+ action: z.string().optional(),
+
+ // 에러 관련 정보
+ error: z.string().optional(),
+
+ // 보안 위반 관련 정보
+ type: z.string().optional(), // security_violation의 타입
+
+ // 클라이언트 환경 정보
+ timestamp: z.string(),
+ userAgent: z.string(),
+ url: z.string(),
+ sessionId: z.string(),
+
+ // 기타 정보
+ userInitiated: z.boolean().optional(),
+ disableLogging: z.boolean().optional(),
+ }),
+});
+
+
+// 클라이언트 로그를 파일 다운로드 로그 형식으로 변환
+function mapClientLogToDownloadLog(event: string, data: any, requestInfo: any, userId: string) {
+ // 파일 ID는 클라이언트에서 알 수 없으므로 0으로 설정 (또는 파일 경로로 조회 시도)
+ const fileId = 0;
+
+ // 성공 여부 판단
+ const success = event === 'download_success';
+
+ // 에러 메시지 설정
+ let errorMessage = undefined;
+ if (event === 'download_error') {
+ errorMessage = data.error || 'Unknown client error';
+ } else if (event === 'security_violation') {
+ errorMessage = `Security violation: ${data.type || 'unknown'} - ${data.error || ''}`;
+ }
+
+ // 요청 ID 생성 (클라이언트 세션 ID 활용)
+ const requestId = data.sessionId || crypto.randomUUID();
+
+ return {
+ fileId,
+ success,
+ errorMessage,
+ requestId,
+ downloadDurationMs: data.duration,
+ fileInfo: {
+ fileName: data.fileName,
+ filePath: data.filePath,
+ fileSize: data.fileSize || 0,
+ },
+ // 클라이언트 로그 추가 정보
+ clientEventType: event,
+ clientUserAgent: data.userAgent,
+ clientUrl: data.url,
+ clientTimestamp: data.timestamp,
+ serverIp: requestInfo.ip,
+ serverUserAgent: requestInfo.userAgent,
+ };
+}
+
+export async function POST(request: NextRequest) {
+ 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
+ });
+
+ 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
+ });
+
+ return NextResponse.json(
+ { error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // 요청 본문 파싱 및 검증
+ const body = await request.json();
+ const validatedData = clientLogSchema.parse(body);
+
+ const { event, data } = validatedData;
+
+ // 로깅이 비활성화된 경우 스킵 (하지만 보안 위반은 항상 기록)
+ if (data.disableLogging && event !== 'security_violation') {
+ return NextResponse.json({ success: true, logged: false });
+ }
+
+ // 클라이언트 로그를 서버 로그 형식으로 변환
+ const downloadLogData = mapClientLogToDownloadLog(event, data, requestInfo, session.user.id);
+
+ // 기존 파일 다운로드 로그 서비스 사용
+ const result = await createFileDownloadLog(downloadLogData);
+
+ if (!result.success) {
+ console.error('클라이언트 로그 저장 실패:', result.error);
+ return NextResponse.json(
+ { error: "Failed to save log" },
+ { status: 500 }
+ );
+ }
+
+ // 성공 로그 (보안 위반은 경고 레벨로)
+ if (event === 'security_violation') {
+ console.warn('🚨 클라이언트 보안 위반 기록:', {
+ type: data.type,
+ filePath: data.filePath,
+ fileName: data.fileName,
+ userId: session.user.id,
+ ip: requestInfo.ip,
+ clientInfo: {
+ userAgent: data.userAgent,
+ url: data.url,
+ sessionId: data.sessionId,
+ }
+ });
+ } else {
+ console.log('📝 클라이언트 로그 기록:', {
+ event,
+ fileName: data.fileName,
+ userId: session.user.id,
+ success: event === 'download_success',
+ logId: result.logId,
+ });
+ }
+
+ return NextResponse.json({
+ success: true,
+ logged: true,
+ logId: result.logId
+ });
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+
+ console.error('❌ 클라이언트 로그 API 오류:', {
+ error: errorMessage,
+ userId: (await getServerSession(authOptions))?.user?.id,
+ ip: requestInfo.ip,
+ userAgent: requestInfo.userAgent,
+ });
+
+ // Zod 검증 에러 처리
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ {
+ error: 'Invalid log data',
+ details: error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')
+ },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ {
+ error: 'Internal server error',
+ details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
+ },
+ { status: 500 }
+ );
+ }
+}
+
+// GET 요청으로 클라이언트 로그 조회 (관리자용)
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user || !session.user.roles?.includes('admin')) {
+ return NextResponse.json(
+ { error: "Admin access required" },
+ { status: 403 }
+ );
+ }
+
+ // 쿼리 파라미터 파싱
+ const searchParams = request.nextUrl.searchParams;
+ const page = parseInt(searchParams.get('page') || '1', 10);
+ const limit = parseInt(searchParams.get('limit') || '50', 10);
+ const event = searchParams.get('event');
+ const userId = searchParams.get('userId');
+ const startDate = searchParams.get('startDate');
+ const endDate = searchParams.get('endDate');
+
+ // 여기서는 기존 로그 조회 함수를 사용하거나
+ // 클라이언트 로그 전용 조회 함수를 만들어야 합니다
+ // 현재는 간단한 응답으로 대체
+
+ return NextResponse.json({
+ message: "Client logs retrieval - implement with your existing log service",
+ filters: { page, limit, event, userId, startDate, endDate }
+ });
+
+ } catch (error) {
+ console.error('❌ 클라이언트 로그 조회 오류:', error);
+ return NextResponse.json(
+ { error: 'Failed to retrieve logs' },
+ { status: 500 }
+ );
+ }
+}
+
+// 클라이언트 로그 통계 조회 (관리자용)
+export async function HEAD(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user || !session.user.roles?.includes('admin')) {
+ return new NextResponse(null, { status: 403 });
+ }
+
+ // 간단한 통계 정보를 헤더로 반환
+ const headers = new Headers();
+ headers.set('X-Total-Logs', '0'); // 실제 구현 필요
+ headers.set('X-Security-Violations', '0'); // 실제 구현 필요
+ headers.set('X-Success-Rate', '0%'); // 실제 구현 필요
+
+ return new NextResponse(null, { status: 200, headers });
+
+ } catch (error) {
+ console.error('❌ 클라이언트 로그 통계 조회 오류:', error);
+ return new NextResponse(null, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/notifications/[id]/deleate/route.ts b/app/api/notifications/[id]/deleate/route.ts
new file mode 100644
index 00000000..21123c78
--- /dev/null
+++ b/app/api/notifications/[id]/deleate/route.ts
@@ -0,0 +1,34 @@
+// app/api/notifications/[id]/delete/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { deleteNotification } from '@/lib/notification/service';
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const deletedNotification = await deleteNotification(params.id, session.user.id);
+
+ if (!deletedNotification) {
+ return NextResponse.json(
+ { error: 'Notification not found' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting notification:', error);
+ return NextResponse.json(
+ { error: 'Failed to delete notification' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/notifications/[id]/read/route.ts b/app/api/notifications/[id]/read/route.ts
new file mode 100644
index 00000000..ba894fad
--- /dev/null
+++ b/app/api/notifications/[id]/read/route.ts
@@ -0,0 +1,34 @@
+// app/api/notifications/[id]/read/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { markNotificationAsRead } from '@/lib/notification/service';
+
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const notification = await markNotificationAsRead(params.id, session.user.id);
+
+ if (!notification) {
+ return NextResponse.json(
+ { error: 'Notification not found' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({ success: true, notification });
+ } catch (error) {
+ console.error('Error marking notification as read:', error);
+ return NextResponse.json(
+ { error: 'Failed to mark notification as read' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/notifications/read-all/route.ts b/app/api/notifications/read-all/route.ts
new file mode 100644
index 00000000..f53bbbbf
--- /dev/null
+++ b/app/api/notifications/read-all/route.ts
@@ -0,0 +1,26 @@
+// app/api/notifications/read-all/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+export async function PATCH(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const notifications = await markAllNotificationsAsRead(session.user.id);
+
+ return NextResponse.json({
+ success: true,
+ updatedCount: notifications.length
+ });
+ } catch (error) {
+ console.error('Error marking all notifications as read:', error);
+ return NextResponse.json(
+ { error: 'Failed to mark all notifications as read' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts
new file mode 100644
index 00000000..cab0b74e
--- /dev/null
+++ b/app/api/notifications/route.ts
@@ -0,0 +1,37 @@
+// app/api/notifications/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { getUserNotifications,getUnreadNotificationCount } from '@/lib/notification/service';
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const limit = parseInt(searchParams.get('limit') || '20');
+ const offset = parseInt(searchParams.get('offset') || '0');
+ const unreadOnly = searchParams.get('unreadOnly') === 'true';
+
+ const [notifications, unreadCount] = await Promise.all([
+ getUserNotifications(session.user.id, { limit, offset, unreadOnly }),
+ getUnreadNotificationCount(session.user.id)
+ ]);
+
+ return NextResponse.json({
+ notifications,
+ unreadCount,
+ hasMore: notifications.length === limit
+ });
+ } catch (error) {
+ console.error('Error fetching notifications:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch notifications' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/notifications/stats/route.ts b/app/api/notifications/stats/route.ts
new file mode 100644
index 00000000..2e99eb3c
--- /dev/null
+++ b/app/api/notifications/stats/route.ts
@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import realtimeNotificationService from '@/lib/realtime/RealtimeNotificationService';
+import notificationManager from '@/lib/realtime/NotificationManager';
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.roles || !session.user.roles.includes('admin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const stats = {
+ connectedClients: realtimeNotificationService.getConnectedClientCount(),
+ dbConnectionStatus: notificationManager.getConnectionStatus(),
+ timestamp: new Date().toISOString()
+ };
+
+ return NextResponse.json(stats);
+ } catch (error) {
+ console.error('Error fetching notification stats:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch stats' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/notifications/stream/route.ts b/app/api/notifications/stream/route.ts
new file mode 100644
index 00000000..7ca6aab2
--- /dev/null
+++ b/app/api/notifications/stream/route.ts
@@ -0,0 +1,54 @@
+// app/api/notifications/stream/route.ts
+import { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import realtimeNotificationService from '@/lib/realtime/RealtimeNotificationService';
+
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const userId = session.user.id;
+ const encoder = new TextEncoder();
+
+ const stream = new ReadableStream({
+ start(controller) {
+ // 클라이언트 등록
+ const clientId = realtimeNotificationService.addClient(userId, controller);
+
+ console.log(`SSE stream started for user ${userId} (${clientId})`);
+
+ // 초기 연결 확인 메시지
+ controller.enqueue(encoder.encode("data: {\"type\":\"connected\"}\n\n"));
+
+ // 연결 해제 처리
+ request.signal.addEventListener('abort', () => {
+ console.log(`SSE stream ended for user ${userId} (${clientId})`);
+ realtimeNotificationService.removeClient(userId, controller);
+ controller.close();
+ });
+ },
+
+ cancel() {
+ console.log(`SSE stream cancelled for user ${userId}`);
+ realtimeNotificationService.removeClient(userId, controller);
+ }
+ });
+
+ return new Response(stream, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache, no-transform',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no', // Nginx 버퍼링 비활성화
+ },
+ });
+ } catch (error) {
+ console.error('Error creating SSE stream:', error);
+ return new Response('Internal Server Error', { 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
index 05e87906..5a07bc0b 100644
--- a/app/api/rfq-attachments/download/route.ts
+++ b/app/api/rfq-attachments/download/route.ts
@@ -1,44 +1,161 @@
// app/api/rfq-attachments/download/route.ts
import { NextRequest, NextResponse } from 'next/server';
-import { readFile, access, constants } from 'fs/promises';
-import { join } from 'path';
-import db from '@/db/db';
+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, or } from 'drizzle-orm';
+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 path = request.nextUrl.searchParams.get("path");
- const type = request.nextUrl.searchParams.get("type"); // "client" | "vendor"
- const revisionId = request.nextUrl.searchParams.get("revisionId");
- const responseFileId = request.nextUrl.searchParams.get("responseFileId");
+ // 파라미터 검증
+ 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 (!path) {
+ // 파일 경로 보안 검증
+ if (!validateFilePath(path)) {
+ console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, {
+ userId: session.user.id,
+ ip: requestInfo.ip,
+ userAgent: requestInfo.userAgent
+ });
+
return NextResponse.json(
- { error: "File path is required" },
+ { error: "Invalid file path" },
{ status: 400 }
);
}
+ // 경로 정규화
+ const normalizedPath = normalize(path.replace(/^\/+/, ""));
+
// DB에서 파일 정보 조회
- let dbRecord = null;
+ 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,
@@ -54,6 +171,7 @@ export async function GET(request: NextRequest) {
// 벤더 응답 파일
const [record] = await db
.select({
+ id: vendorResponseAttachmentsB.id,
fileName: vendorResponseAttachmentsB.fileName,
originalFileName: vendorResponseAttachmentsB.originalFileName,
filePath: vendorResponseAttachmentsB.filePath,
@@ -66,9 +184,10 @@ export async function GET(request: NextRequest) {
dbRecord = record;
} else {
- // filePath로 직접 검색 (fallback)
+ // filePath로 직접 검색 (fallback) - 정규화된 경로로 검색
const [clientRecord] = await db
.select({
+ id: bRfqAttachmentRevisions.id,
fileName: bRfqAttachmentRevisions.fileName,
originalFileName: bRfqAttachmentRevisions.originalFileName,
filePath: bRfqAttachmentRevisions.filePath,
@@ -76,7 +195,7 @@ export async function GET(request: NextRequest) {
fileType: bRfqAttachmentRevisions.fileType,
})
.from(bRfqAttachmentRevisions)
- .where(eq(bRfqAttachmentRevisions.filePath, path));
+ .where(eq(bRfqAttachmentRevisions.filePath, normalizedPath));
if (clientRecord) {
dbRecord = clientRecord;
@@ -84,6 +203,7 @@ export async function GET(request: NextRequest) {
// 벤더 파일에서도 검색
const [vendorRecord] = await db
.select({
+ id: vendorResponseAttachmentsB.id,
fileName: vendorResponseAttachmentsB.fileName,
originalFileName: vendorResponseAttachmentsB.originalFileName,
filePath: vendorResponseAttachmentsB.filePath,
@@ -91,72 +211,142 @@ export async function GET(request: NextRequest) {
fileType: vendorResponseAttachmentsB.fileType,
})
.from(vendorResponseAttachmentsB)
- .where(eq(vendorResponseAttachmentsB.filePath, path));
+ .where(eq(vendorResponseAttachmentsB.filePath, normalizedPath));
dbRecord = vendorRecord;
}
}
- // 파일 정보 설정
- let fileName;
- let fileType;
+ // DB에서 파일 정보를 찾지 못한 경우
+ if (!dbRecord) {
+ console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", {
+ path,
+ normalizedPath,
+ userId: session.user.id,
+ ip: requestInfo.ip
+ });
- if (dbRecord) {
- // DB에서 찾은 경우 원본 파일명 사용
- fileName = dbRecord.originalFileName || dbRecord.fileName;
- fileType = dbRecord.fileType;
- console.log("✅ DB에서 파일 정보 찾음:", { fileName, fileType, path: dbRecord.filePath });
- } else {
- // DB에서 찾지 못한 경우 경로에서 파일명 추출
- fileName = path.split('/').pop() || 'download';
- console.log("⚠️ DB에서 파일 정보를 찾지 못함, 경로에서 추출:", fileName);
+ 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 storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거
+ // 안전한 파일 경로 구성
+ const allowedDirs = ["public", "uploads", "storage"];
+ let actualPath: string | null = null;
+ let baseDir: string | null = null;
- // 가능한 파일 경로들
- const possiblePaths = [
- join(process.cwd(), "public", storedPath),
- join(process.cwd(), "uploads", storedPath),
- join(process.cwd(), "storage", storedPath),
- join(process.cwd(), storedPath), // 절대 경로인 경우
- ];
+ // 각 허용된 디렉터리에서 파일 찾기
+ for (const dir of allowedDirs) {
+ baseDir = resolve(process.cwd(), dir);
+ const testPath = resolve(baseDir, normalizedPath);
+
+ // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단
+ if (!testPath.startsWith(baseDir)) {
+ continue;
+ }
- // 실제 파일 찾기
- let actualPath = null;
- for (const testPath of possiblePaths) {
try {
await access(testPath, constants.R_OK);
actualPath = testPath;
console.log("✅ 파일 발견:", testPath);
break;
} catch (err) {
- // 조용히 다음 경로 시도
+ // 조용히 다음 디렉터리 시도
}
}
- if (!actualPath) {
- console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", possiblePaths);
+ 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",
- details: {
- path: path,
- fileName: fileName,
- triedPaths: possiblePaths
- }
- },
+ { 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 = fileType || 'application/octet-stream'; // DB의 fileType 우선 사용
+ let contentType = dbRecord.fileType || 'application/octet-stream';
// 확장자에 따른 MIME 타입 매핑 (fallback)
if (!contentType || contentType === 'application/octet-stream') {
@@ -168,8 +358,8 @@ export async function GET(request: NextRequest) {
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'txt': 'text/plain',
- 'csv': 'text/csv',
+ 'txt': 'text/plain; charset=utf-8',
+ 'csv': 'text/csv; charset=utf-8',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
@@ -186,23 +376,44 @@ export async function GET(request: NextRequest) {
contentType = mimeTypes[fileExtension] || 'application/octet-stream';
}
- // 안전한 파일명 생성 (특수문자 처리)
- const safeFileName = fileName.replace(/[^\w\s.-]/gi, '_');
+ // 안전한 파일명 생성
+ 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
+ actualPath,
+ userId: session.user.id,
+ ip: requestInfo.ip,
+ downloadDurationMs: Date.now() - startTime
});
return new NextResponse(fileBuffer, {
@@ -211,11 +422,51 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
- console.error('❌ RFQ 첨부파일 다운로드 오류:', 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: 'Failed to download file',
- details: error instanceof Error ? error.message : String(error)
+ error: 'Internal server error',
+ details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
},
{ status: 500 }
);
diff --git a/app/api/rfq-download/route.ts b/app/api/rfq-download/route.ts
index 19991128..92607e05 100644
--- a/app/api/rfq-download/route.ts
+++ b/app/api/rfq-download/route.ts
@@ -1,85 +1,174 @@
// app/api/rfq-download/route.ts
import { NextRequest, NextResponse } from 'next/server';
-import { readFile, access, constants } from 'fs/promises';
-import { join } from 'path';
+import { readFile, access, constants, stat } from 'fs/promises';
+import { join, normalize, resolve } from 'path';
import db from '@/db/db';
import { rfqAttachments } from '@/db/schema/rfq';
import { eq } from 'drizzle-orm';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import rateLimit from '@/lib/rate-limit'; // Rate limiting 함수
+import { createFileDownloadLog } from '@/lib/file-download-log/service';
+
+// 허용된 파일 확장자
+const ALLOWED_EXTENSIONS = new Set([
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
+ 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif'
+]);
+
+// 최대 파일 크기 (50MB)
+const MAX_FILE_SIZE = 50 * 1024 * 1024;
+
+// 파일 경로 검증 함수
+function validateFilePath(filePath: string): boolean {
+ // null, undefined, 빈 문자열 체크
+ if (!filePath || typeof filePath !== 'string') {
+ return false;
+ }
+
+ // 위험한 패턴 체크
+ const dangerousPatterns = [
+ /\.\./, // 상위 디렉토리 접근
+ /\/\//, // 이중 슬래시
+ /[<>:"'|?*]/, // 특수문자
+ /[\x00-\x1f]/, // 제어문자
+ /^\/+/, // 절대경로
+ /\\+/ // 백슬래시
+ ];
+
+ return !dangerousPatterns.some(pattern => pattern.test(filePath));
+}
+
+// 파일 확장자 검증
+function validateFileExtension(fileName: string): boolean {
+ const extension = fileName.split('.').pop()?.toLowerCase() || '';
+ return ALLOWED_EXTENSIONS.has(extension);
+}
+
+// 안전한 파일명 생성
+function sanitizeFileName(fileName: string): string {
+ return fileName
+ .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거
+ .replace(/\s+/g, '_') // 공백을 언더스코어로
+ .substring(0, 255); // 파일명 길이 제한
+}
+
+
export async function GET(request: NextRequest) {
try {
- // 파일 경로 파라미터 받기
- const path = request.nextUrl.searchParams.get("path");
-
- if (!path) {
+ // Rate limiting 체크
+ const limiterResult = await rateLimit(request);
+ if (!limiterResult.success) {
+ return NextResponse.json(
+ { error: "Too many requests" },
+ { status: 429 }
+ );
+ }
+
+ // 사용자 인증 확인
+ const session = await getServerSession(authOptions);
+ if (!session || !session.user) {
+ return NextResponse.json(
+ { error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // 파일 경로 파라미터 받기 및 검증
+ const rawPath = request.nextUrl.searchParams.get("path");
+
+ if (!rawPath) {
return NextResponse.json(
{ error: "File path is required" },
{ status: 400 }
);
}
-
- // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색)
+
+ // 파일 경로 검증
+ if (!validateFilePath(rawPath)) {
+ return NextResponse.json(
+ { error: "Invalid file path" },
+ { status: 400 }
+ );
+ }
+
+ // 경로 정규화
+ const normalizedPath = normalize(rawPath.replace(/^\/+/, ""));
+
+ // DB에서 파일 정보 조회
const [dbRecord] = await db
.select({
+ id: rfqAttachments.id,
fileName: rfqAttachments.fileName,
- filePath: rfqAttachments.filePath
+ filePath: rfqAttachments.filePath,
})
.from(rfqAttachments)
- .where(eq(rfqAttachments.filePath, path));
-
- // 파일 정보 설정
- let fileName;
-
- if (dbRecord) {
- // DB에서 찾은 경우 원본 파일명 사용
- fileName = dbRecord.fileName;
- console.log("DB에서 원본 파일명 찾음:", fileName);
- } else {
- // DB에서 찾지 못한 경우 경로에서 파일명 추출
- fileName = path.split('/').pop() || 'download';
+ .where(eq(rfqAttachments.filePath, normalizedPath));
+
+ if (!dbRecord) {
+ return NextResponse.json(
+ { error: "File not found in database" },
+ { status: 404 }
+ );
}
-
- // 파일 경로 구성
- const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거
-
- // 파일 경로 시도
- const possiblePaths = [
- join(process.cwd(), "public", storedPath)
- ];
-
- // 실제 파일 찾기
- let actualPath = null;
- for (const testPath of possiblePaths) {
- try {
- await access(testPath, constants.R_OK);
- actualPath = testPath;
- break;
- } catch (err) {
- console.log("❌ 경로에 파일 없음:", testPath);
- }
+
+ // 파일 확장자 검증
+ if (!validateFileExtension(dbRecord.fileName)) {
+ return NextResponse.json(
+ { error: "File type not allowed" },
+ { status: 403 }
+ );
+ }
+
+ // 사용자 권한 확인
+ const userId = session.user?.id || session.user?.email;
+ if (!userId) {
+ return NextResponse.json(
+ { error: "User ID not found" },
+ { status: 400 }
+ );
+ }
+
+ // 안전한 파일 경로 구성
+ const publicDir = resolve(process.cwd(), "public");
+ const fullPath = resolve(publicDir, normalizedPath);
+
+ // 경로 탐색 공격 방지 - public 디렉토리 외부 접근 차단
+ if (!fullPath.startsWith(publicDir)) {
+ return NextResponse.json(
+ { error: "Access denied" },
+ { status: 403 }
+ );
}
-
- if (!actualPath) {
+
+ // 파일 존재 및 읽기 권한 확인
+ try {
+ await access(fullPath, constants.R_OK);
+ } catch (error) {
return NextResponse.json(
- {
- error: "File not found on server",
- details: {
- path: path,
- triedPaths: possiblePaths
- }
- },
+ { error: "File not accessible" },
{ status: 404 }
);
}
-
- const fileBuffer = await readFile(actualPath);
-
+
+ // 파일 크기 확인
+ const stats = await stat(fullPath);
+ if (stats.size > MAX_FILE_SIZE) {
+ return NextResponse.json(
+ { error: "File too large" },
+ { status: 413 }
+ );
+ }
+
+ // 파일 읽기
+ const fileBuffer = await readFile(fullPath);
+
+ // 안전한 파일명 생성
+ const safeFileName = sanitizeFileName(dbRecord.fileName);
+
// MIME 타입 결정
- const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
-
- let contentType = 'application/octet-stream'; // 기본 바이너리
-
- // 확장자에 따른 MIME 타입 매핑
+ const fileExtension = safeFileName.split('.').pop()?.toLowerCase() || '';
const mimeTypes: Record<string, string> = {
'pdf': 'application/pdf',
'doc': 'application/msword',
@@ -88,33 +177,68 @@ export async function GET(request: NextRequest) {
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'txt': 'text/plain',
- 'csv': 'text/csv',
+ 'txt': 'text/plain; charset=utf-8',
+ 'csv': 'text/csv; charset=utf-8',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
};
-
- contentType = mimeTypes[fileExtension] || contentType;
-
- // 다운로드용 헤더 설정
+
+ const contentType = mimeTypes[fileExtension] || 'application/octet-stream';
+
+ // 보안 헤더 설정
const headers = new Headers();
headers.set('Content-Type', contentType);
- headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
+ headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`);
headers.set('Content-Length', fileBuffer.length.toString());
-
+ headers.set('X-Content-Type-Options', 'nosniff');
+ headers.set('X-Frame-Options', 'DENY');
+ headers.set('X-XSS-Protection', '1; mode=block');
+ headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
+ headers.set('Pragma', 'no-cache');
+ headers.set('Expires', '0');
+
+ // 다운로드 로그 기록 (보안 감사를 위해)
+ console.log(`파일 다운로드: ${userId} - ${safeFileName} - ${new Date().toISOString()}`);
+
+ // 감사 로그 기록
+ await createFileDownloadLog({ fileId: dbRecord.id, success: true, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } });
+
return new NextResponse(fileBuffer, {
status: 200,
headers,
});
+
} catch (error) {
console.error('❌ RFQ 파일 다운로드 오류:', error);
+
+ // 실패 감사 로그 기록
+ try {
+ const rawPath = request.nextUrl.searchParams.get("path");
+ if (rawPath) {
+ const normalizedPath = normalize(rawPath.replace(/^\/+/, ""));
+ const [dbRecord] = await db
+ .select({
+ id: rfqAttachments.id,
+ fileName: rfqAttachments.fileName,
+ filePath: rfqAttachments.filePath,
+ })
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.filePath, normalizedPath));
+
+ if (dbRecord) {
+ await createFileDownloadLog({ fileId: dbRecord.id, success: false, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } });
+
+ }
+ }
+ } catch (logError) {
+ console.error('감사 로그 기록 실패:', logError);
+ }
+
+ // 에러 정보 최소화 (정보 노출 방지)
return NextResponse.json(
- {
- error: 'Failed to download file',
- details: String(error)
- },
+ { error: 'Internal server error' },
{ status: 500 }
);
}
diff --git a/app/api/tbe-download/route.ts b/app/api/tbe-download/route.ts
index 12e42920..93eb62db 100644
--- a/app/api/tbe-download/route.ts
+++ b/app/api/tbe-download/route.ts
@@ -1,119 +1,415 @@
-// app/api/rfq-download/route.ts
+// app/api/tbe-download/route.ts
import { NextRequest, NextResponse } from 'next/server';
-import { readFile, access, constants } from 'fs/promises';
-import { join } from 'path';
+import { readFile, access, constants, stat } from 'fs/promises';
+import { join, normalize, resolve } from 'path';
import db from '@/db/db';
import { rfqAttachments, 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 {
- // 파일 경로 파라미터 받기
- const path = request.nextUrl.searchParams.get("path");
-
- if (!path) {
+ // 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: "File path is required" },
+ { 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
+ filePath: vendorResponseAttachments.filePath,
+ fileType: vendorResponseAttachments.fileType,
})
.from(vendorResponseAttachments)
- .where(eq(vendorResponseAttachments.filePath, path));
-
- // 파일 정보 설정
- let fileName;
-
- if (dbRecord) {
- // DB에서 찾은 경우 원본 파일명 사용
- fileName = dbRecord.fileName;
- console.log("DB에서 원본 파일명 찾음:", fileName);
- } else {
- // DB에서 찾지 못한 경우 경로에서 파일명 추출
- fileName = path.split('/').pop() || 'download';
+ .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 }
+ );
}
-
- // 파일 경로 구성
- const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거
-
- // 파일 경로 시도
- const possiblePaths = [
- join(process.cwd(), "public", storedPath)
- ];
-
- // 실제 파일 찾기
- let actualPath = null;
- for (const testPath of possiblePaths) {
+
+ 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) {
+
+ 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,
- triedPaths: possiblePaths
+ 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 = 'application/octet-stream'; // 기본 바이너리
-
- // 확장자에 따른 MIME 타입 매핑
- 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',
- 'csv': 'text/csv',
- 'png': 'image/png',
- 'jpg': 'image/jpeg',
- 'jpeg': 'image/jpeg',
- 'gif': 'image/gif',
- };
-
- contentType = mimeTypes[fileExtension] || contentType;
-
- // 다운로드용 헤더 설정
+ 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="${encodeURIComponent(fileName)}"`);
+ 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) {
- console.error('❌ RFQ 파일 다운로드 오류:', 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: 'Failed to download file',
- details: String(error)
+ {
+ error: 'Internal server error',
+ details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
},
{ status: 500 }
);
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