diff options
Diffstat (limited to 'app/api/data-room')
| -rw-r--r-- | app/api/data-room/[projectId]/[fileId]/download/route.ts | 246 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/[fileId]/route.ts | 147 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/download-folder/[folderId]/route.ts | 289 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/download-multiple/route.ts | 162 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/permissions/route.ts | 74 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/route.ts | 118 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/share/[token]/route.ts | 45 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/share/route.ts | 79 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/upload/route.ts | 139 |
9 files changed, 1299 insertions, 0 deletions
diff --git a/app/api/data-room/[projectId]/[fileId]/download/route.ts b/app/api/data-room/[projectId]/[fileId]/download/route.ts new file mode 100644 index 00000000..3a3a8fdd --- /dev/null +++ b/app/api/data-room/[projectId]/[fileId]/download/route.ts @@ -0,0 +1,246 @@ +// app/api/data-room/[projectId]/[fileId]/download/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq } from "drizzle-orm"; + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // 파일 접근 권한 확인 + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'download' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 다운로드 권한이 없습니다' }, + { status: 403 } + ); + } + + // FileService를 통해 파일 정보 가져오기 (다운로드 카운트 증가 및 로그 기록) + const file = await fileService.downloadFile(params.fileId, context); + + if (!file) { + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 파일 경로 확인 + if (!file.filePath) { + return NextResponse.json( + { error: '파일 경로가 없습니다' }, + { status: 404 } + ); + } + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + // 프로덕션: NAS 경로 사용 + const relativePath = file.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + // 개발: public 폴더 사용 + absolutePath = path.join(process.cwd(), 'public', file.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + } catch (error) { + console.error('파일을 찾을 수 없습니다:', absolutePath); + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 파일 읽기 + const fileBuffer = await fs.readFile(absolutePath); + + // MIME 타입 결정 + const mimeType = getMimeType(file.name, file.mimeType); + + // 파일명 인코딩 (한글 등 특수문자 처리) + const encodedFileName = encodeURIComponent(file.name); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', mimeType); + headers.set('Content-Length', fileBuffer.length.toString()); + headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); + 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'); + + // 파일 스트림 반환 + return new NextResponse(fileBuffer, { + status: 200, + headers, + }); + + } catch (error) { + console.error('파일 다운로드 오류:', error); + + if (error instanceof Error) { + if (error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + } + + return NextResponse.json( + { error: '파일 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} + +// HEAD 요청 처리 (파일 정보만 확인) +export async function HEAD( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse(null, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // 파일 접근 권한 확인 + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'view' // HEAD 요청은 view 권한만 확인 + ); + + if (!hasAccess) { + return new NextResponse(null, { status: 403 }); + } + + // 파일 정보 조회 + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, params.fileId), + }); + + if (!file || !file.filePath) { + return new NextResponse(null, { status: 404 }); + } + + const headers = new Headers(); + headers.set('Content-Type', getMimeType(file.name, file.mimeType)); + headers.set('Content-Length', file.size?.toString() || '0'); + headers.set('Last-Modified', new Date(file.updatedAt).toUTCString()); + + return new NextResponse(null, { + status: 200, + headers, + }); + + } catch (error) { + console.error('HEAD 요청 오류:', error); + return new NextResponse(null, { status: 500 }); + } +} + +// MIME 타입 결정 헬퍼 함수 +function getMimeType(fileName: string, storedMimeType?: string | null): string { + // DB에 저장된 MIME 타입이 있으면 우선 사용 + if (storedMimeType) { + return storedMimeType; + } + + // 확장자 기반 MIME 타입 매핑 + const ext = path.extname(fileName).toLowerCase().substring(1); + const mimeTypes: Record<string, string> = { + // Documents + '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', + + // Images + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + + // Archives + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + + // CAD + 'dwg': 'application/x-dwg', + 'dxf': 'application/x-dxf', + + // Video + 'mp4': 'video/mp4', + 'avi': 'video/x-msvideo', + 'mov': 'video/quicktime', + 'wmv': 'video/x-ms-wmv', + + // Audio + 'mp3': 'audio/mpeg', + 'wav': 'audio/wav', + 'ogg': 'audio/ogg', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/[fileId]/route.ts b/app/api/data-room/[projectId]/[fileId]/route.ts new file mode 100644 index 00000000..176aaf63 --- /dev/null +++ b/app/api/data-room/[projectId]/[fileId]/route.ts @@ -0,0 +1,147 @@ +// app/api/files/[projectId]/[fileId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; + +// 파일 정보 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'view' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 접근 권한이 없습니다' }, + { status: 403 } + ); + } + + // 파일 정보 반환 + const file = await fileService.downloadFile(params.fileId, context); + + if (!file) { + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + return NextResponse.json(file); + } catch (error) { + console.error('파일 조회 오류:', error); + return NextResponse.json( + { error: '파일 조회에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 파일 수정 +export async function PATCH( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'edit' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 수정 권한이 없습니다' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // 파일 이동 처리 + if (body.parentId !== undefined) { + await fileService.moveFile(params.fileId, body.parentId, context); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('파일 수정 오류:', error); + return NextResponse.json( + { error: '파일 수정에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 파일 삭제 +export async function DELETE( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + await fileService.deleteFile(params.fileId, context); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('파일 삭제 오류:', error); + return NextResponse.json( + { error: '파일 삭제에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts new file mode 100644 index 00000000..bba7066f --- /dev/null +++ b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts @@ -0,0 +1,289 @@ +// app/api/data-room/[projectId]/download-folder/[folderId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq, and } from "drizzle-orm"; + +interface FileWithPath { + file: any; + absolutePath: string; + relativePath: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + // 폴더 정보 가져오기 + const folder = await db.query.fileItems.findFirst({ + where: and( + eq(fileItems.id, params.folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + if (!folder || folder.type !== 'folder') { + return NextResponse.json( + { error: '폴더를 찾을 수 없습니다' }, + { status: 404 } + ); + } + + const fileService = new FileService(); + const downloadableFiles: FileWithPath[] = []; + const unauthorizedFiles: string[] = []; + + // 재귀적으로 폴더 내 모든 파일 가져오기 및 권한 확인 + const processFolder = async ( + folderId: string, + folderPath: string = '' + ): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + // 파일 권한 확인 + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + // 권한이 없는 파일 기록 + unauthorizedFiles.push(path.join(folderPath, item.name)); + continue; + } + + if (!item.filePath) continue; + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + const relativePath = item.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', item.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + downloadableFiles.push({ + file: item, + absolutePath, + relativePath: path.join(folderPath, item.name) + }); + + // 다운로드 카운트 증가 및 로그 기록 + await fileService.downloadFile(item.id, context); + } catch (error) { + console.warn(`파일을 찾을 수 없습니다: ${absolutePath}`); + } + } else if (item.type === 'folder') { + // 하위 폴더 재귀 처리 + await processFolder( + item.id, + path.join(folderPath, item.name) + ); + } + } + }; + + // 폴더 처리 시작 + await processFolder(params.folderId, folder.name); + + // 권한이 없는 파일이 있으면 다운로드 차단 + if (unauthorizedFiles.length > 0) { + return NextResponse.json( + { + error: '일부 파일에 대한 다운로드 권한이 없습니다', + unauthorizedFiles: unauthorizedFiles, + unauthorizedCount: unauthorizedFiles.length, + message: `다음 파일들에 대한 권한이 없어 폴더 다운로드가 취소되었습니다: ${unauthorizedFiles.slice(0, 5).join(', ')}${unauthorizedFiles.length > 5 ? ` 외 ${unauthorizedFiles.length - 5}개` : ''}` + }, + { status: 403 } + ); + } + + // 다운로드할 파일이 없는 경우 + if (downloadableFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다' }, + { status: 404 } + ); + } + + // 파일 크기 합계 체크 (최대 500MB) + const totalSize = downloadableFiles.reduce((sum, item) => + sum + (item.file.size || 0), 0 + ); + + const maxSize = 500 * 1024 * 1024; // 500MB + if (totalSize > maxSize) { + return NextResponse.json( + { + error: `폴더 크기가 너무 큽니다 (${(totalSize / 1024 / 1024).toFixed(2)}MB). 최대 500MB까지 다운로드 가능합니다.`, + totalSize: totalSize, + maxSize: maxSize, + fileCount: downloadableFiles.length + }, + { status: 400 } + ); + } + + console.log(`📦 폴더 다운로드 시작: ${folder.name} (${downloadableFiles.length}개 파일, ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); + + // ZIP 스트림 생성 + const archive = archiver('zip', { + zlib: { level: 5 } // 압축 레벨 + }); + + // 스트림을 Response로 변환 + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (chunk) => controller.enqueue(chunk)); + archive.on('end', () => controller.close()); + archive.on('error', (err) => { + console.error('Archive error:', err); + controller.error(err); + }); + }, + }); + + // 파일들을 ZIP에 추가 (폴더 구조 유지) + for (const { file, absolutePath, relativePath } of downloadableFiles) { + try { + const fileBuffer = await fs.readFile(absolutePath); + archive.append(fileBuffer, { name: relativePath }); + } catch (error) { + console.error(`파일 추가 실패: ${relativePath}`, error); + } + } + + // ZIP 완료 + archive.finalize(); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(folder.name)}.zip"`); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + headers.set('X-File-Count', downloadableFiles.length.toString()); + headers.set('X-Total-Size', totalSize.toString()); + + return new NextResponse(stream, { + status: 200, + headers, + }); + + } catch (error) { + console.error('폴더 다운로드 오류:', error); + return NextResponse.json( + { error: '폴더 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 폴더 다운로드 전 권한 체크 (선택적) +export async function HEAD( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse(null, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + let totalFiles = 0; + let unauthorizedCount = 0; + let totalSize = 0; + + // 재귀적으로 권한 체크 + const checkFolder = async (folderId: string): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + totalFiles++; + totalSize += item.size || 0; + + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + unauthorizedCount++; + } + } else if (item.type === 'folder') { + await checkFolder(item.id); + } + } + }; + + await checkFolder(params.folderId); + + const headers = new Headers(); + headers.set('X-Total-Files', totalFiles.toString()); + headers.set('X-Unauthorized-Files', unauthorizedCount.toString()); + headers.set('X-Total-Size', totalSize.toString()); + headers.set('X-Can-Download', unauthorizedCount === 0 ? 'true' : 'false'); + + return new NextResponse(null, { + status: unauthorizedCount > 0 ? 403 : 200, + headers, + }); + + } catch (error) { + console.error('권한 체크 오류:', error); + return new NextResponse(null, { status: 500 }); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/download-multiple/route.ts b/app/api/data-room/[projectId]/download-multiple/route.ts new file mode 100644 index 00000000..64c87b55 --- /dev/null +++ b/app/api/data-room/[projectId]/download-multiple/route.ts @@ -0,0 +1,162 @@ +// app/api/data-room/[projectId]/download-multiple/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import { Readable } from 'stream'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq, inArray } from "drizzle-orm"; + +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const { fileIds } = body; + + if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) { + return NextResponse.json( + { error: '파일 ID가 제공되지 않았습니다' }, + { status: 400 } + ); + } + + // 너무 많은 파일 방지 (최대 100개) + if (fileIds.length > 100) { + return NextResponse.json( + { error: '한 번에 최대 100개의 파일만 다운로드할 수 있습니다' }, + { status: 400 } + ); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const downloadableFiles: Array<{ + file: any; + absolutePath: string; + }> = []; + + // 각 파일의 접근 권한 확인 및 경로 확인 + for (const fileId of fileIds) { + // 권한 확인 + const hasAccess = await fileService.checkFileAccess( + fileId, + context, + 'download' + ); + + if (!hasAccess) { + console.warn(`파일 ${fileId}에 대한 다운로드 권한이 없습니다`); + continue; + } + + // 파일 정보 가져오기 + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, fileId), + }); + + if (!file || !file.filePath || file.type !== 'file') { + console.warn(`파일 ${fileId}를 찾을 수 없거나 폴더입니다`); + continue; + } + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + const relativePath = file.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', file.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + downloadableFiles.push({ file, absolutePath }); + + // 다운로드 카운트 증가 및 로그 기록 + await fileService.downloadFile(fileId, context); + } catch (error) { + console.warn(`파일 ${absolutePath}를 찾을 수 없습니다`); + } + } + + if (downloadableFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다' }, + { status: 404 } + ); + } + + // ZIP 스트림 생성 + const archive = archiver('zip', { + zlib: { level: 5 } // 압축 레벨 (1-9, 5가 균형적) + }); + + // 스트림을 Response로 변환 + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (chunk) => controller.enqueue(chunk)); + archive.on('end', () => controller.close()); + archive.on('error', (err) => controller.error(err)); + }, + }); + + // 파일들을 ZIP에 추가 + for (const { file, absolutePath } of downloadableFiles) { + try { + const fileBuffer = await fs.readFile(absolutePath); + + // 파일명 중복 방지를 위한 고유 이름 생성 + const uniqueName = `${path.parse(file.name).name}_${file.id.slice(0, 8)}${path.extname(file.name)}`; + + archive.append(fileBuffer, { name: uniqueName }); + } catch (error) { + console.error(`파일 추가 실패: ${file.name}`, error); + } + } + + // ZIP 완료 + archive.finalize(); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Disposition', 'attachment; filename="files.zip"'); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + return new NextResponse(stream, { + status: 200, + headers, + }); + + } catch (error) { + console.error('다중 파일 다운로드 오류:', error); + return NextResponse.json( + { error: '다중 파일 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/permissions/route.ts b/app/api/data-room/[projectId]/permissions/route.ts new file mode 100644 index 00000000..94401826 --- /dev/null +++ b/app/api/data-room/[projectId]/permissions/route.ts @@ -0,0 +1,74 @@ +// app/api/files/[projectId]/permissions/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +const grantPermissionSchema = z.object({ + fileId: z.string().uuid(), + targetUserId: z.number().optional().nullable(), + targetDomain: z.string().optional().nullable(), + permissions: z.object({ + canView: z.boolean().optional(), + canDownload: z.boolean().optional(), + canEdit: z.boolean().optional(), + canDelete: z.boolean().optional(), + canShare: z.boolean().optional(), + }), +}); + +// 권한 부여 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = grantPermissionSchema.parse(body); + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + await fileService.grantPermission( + validatedData.fileId, + validatedData.targetUserId, + validatedData.targetDomain, + validatedData.permissions, + context + ); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('권한 부여 오류:', error); + return NextResponse.json( + { error: '권한 부여에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/route.ts b/app/api/data-room/[projectId]/route.ts new file mode 100644 index 00000000..643dcf0f --- /dev/null +++ b/app/api/data-room/[projectId]/route.ts @@ -0,0 +1,118 @@ +// app/api/data-room/[projectId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +// 파일 생성 스키마 검증 +const createFileSchema = z.object({ + name: z.string().min(1).max(255), + type: z.enum(['file', 'folder']), + parentId: z.string().uuid().optional().nullable(), + category: z.enum(['public', 'restricted', 'confidential', 'internal']).default('confidential'), + mimeType: z.string().optional(), + size: z.number().optional(), + filePath: z.string().optional(), +}); + +// 파일 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const parentId = searchParams.get('parentId'); + const viewMode = searchParams.get('viewMode'); // 'tree' or 'grid' + const includeAll = searchParams.get('includeAll') === 'true'; // 전체 목록 가져오기 + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // viewMode가 tree이거나 includeAll이 true인 경우 전체 목록 가져오기 + const files = await fileService.getFileList( + params.projectId, + parentId, + context, + { + includeAll: viewMode === 'tree' || includeAll + } + ); + + return NextResponse.json(files); + } catch (error) { + console.error('파일 목록 조회 오류:', error); + return NextResponse.json( + { error: '파일 목록을 불러올 수 없습니다' }, + { status: 500 } + ); + } +} + +// 파일/폴더 생성 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createFileSchema.parse(body); + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const newFile = await fileService.createFileItem( + { + ...validatedData, + projectId: params.projectId, + }, + context + ); + + return NextResponse.json(newFile, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === '권한이 없습니다') { + return NextResponse.json( + { error: '파일 생성 권한이 없습니다' }, + { status: 403 } + ); + } + + console.error('파일 생성 오류:', error); + return NextResponse.json( + { error: '파일 생성에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/share/[token]/route.ts b/app/api/data-room/[projectId]/share/[token]/route.ts new file mode 100644 index 00000000..51582bca --- /dev/null +++ b/app/api/data-room/[projectId]/share/[token]/route.ts @@ -0,0 +1,45 @@ +// app/api/shared/[token]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { FileService } from '@/lib/services/fileService'; + +// 공유 링크로 파일 접근 +export async function GET( + request: NextRequest, + { params }: { params: { token: string } } +) { + try { + const searchParams = request.nextUrl.searchParams; + const password = searchParams.get('password'); + + const fileService = new FileService(); + const result = await fileService.accessFileByShareToken( + params.token, + password || undefined + ); + + if (!result) { + return NextResponse.json( + { error: '유효하지 않은 공유 링크입니다' }, + { status: 404 } + ); + } + + return NextResponse.json({ + file: result.file, + accessLevel: result.accessLevel, + }); + } catch (error) { + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ); + } + + console.error('공유 파일 접근 오류:', error); + return NextResponse.json( + { error: '파일 접근에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/share/route.ts b/app/api/data-room/[projectId]/share/route.ts new file mode 100644 index 00000000..9b27d9fc --- /dev/null +++ b/app/api/data-room/[projectId]/share/route.ts @@ -0,0 +1,79 @@ +// app/api/files/[projectId]/share/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +const createShareSchema = z.object({ + fileId: z.string().uuid(), + accessLevel: z.enum(['view_only', 'view_download']).optional(), + password: z.string().optional(), + expiresAt: z.string().datetime().optional(), + maxDownloads: z.number().positive().optional(), + sharedWithEmail: z.string().email().optional(), +}); + +// 공유 링크 생성 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createShareSchema.parse(body); + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const shareToken = await fileService.createShareLink( + validatedData.fileId, + { + ...validatedData, + expiresAt: validatedData.expiresAt + ? new Date(validatedData.expiresAt) + : undefined, + }, + context + ); + + const shareUrl = `${process.env.NEXT_PUBLIC_APP_URL}/shared/${shareToken}`; + + return NextResponse.json({ + shareToken, + shareUrl, + success: true + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('공유 링크 생성 오류:', error); + return NextResponse.json( + { error: '공유 링크 생성에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/upload/route.ts b/app/api/data-room/[projectId]/upload/route.ts new file mode 100644 index 00000000..60bbc10f --- /dev/null +++ b/app/api/data-room/[projectId]/upload/route.ts @@ -0,0 +1,139 @@ +// app/api/data-room/[projectId]/upload/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { saveDRMFile, saveFileStream } from '@/lib/file-stroage'; + +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + // 내부 사용자만 업로드 가능 + if (session.user.domain === 'partners') { + return NextResponse.json( + { error: '파일 업로드 권한이 없습니다' }, + { status: 403 } + ); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + const category = formData.get('category') as string; + const parentId = formData.get('parentId') as string | null; + const fileSize = formData.get('fileSize') as string | null; + + if (!file) { + return NextResponse.json( + { error: '파일이 제공되지 않았습니다' }, + { status: 400 } + ); + } + + // 대용량 파일 임계값 (10MB) + const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; + const actualFileSize = fileSize ? parseInt(fileSize) : file.size; + + let result; + + // 파일 크기에 따라 다른 저장 방법 사용 + if (actualFileSize > LARGE_FILE_THRESHOLD) { + console.log(`🚀 대용량 파일 스트리밍 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`); + + // 대용량 파일은 스트리밍 저장 사용 + result = await saveFileStream({ + file, + directory: `projects/${params.projectId}`, + originalName: file.name, + userId: session.user.id + }); + } else { + console.log(`📦 일반 파일 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`); + + // 작은 파일은 기존 DRM 저장 방식 사용 + result = await saveDRMFile( + file, + async (file) => file.arrayBuffer(), // 이미 복호화된 데이터 + `projects/${params.projectId}`, + session.user.id + ); + } + + if (!result.success) { + return NextResponse.json( + { error: result.error || '파일 저장에 실패했습니다' }, + { status: 500 } + ); + } + + // DB에 파일 정보 저장 + const fileService = new FileService(); + const newFile = await fileService.createFileItem( + { + name: result.originalName || file.name, + type: 'file', + category: category as 'public' | 'restricted' | 'confidential' | 'internal', + parentId, + size: result.fileSize || actualFileSize, + mimeType: file.type, + filePath: result.publicPath, + projectId: params.projectId, + }, + context + ); + + return NextResponse.json({ + ...newFile, + uploadResult: { + ...result, + uploadMethod: actualFileSize > LARGE_FILE_THRESHOLD ? 'stream' : 'buffer', + fileSizeMB: (actualFileSize / 1024 / 1024).toFixed(2) + }, + }, { status: 201 }); + + } catch (error) { + console.error('파일 업로드 오류:', error); + return NextResponse.json( + { + error: '파일 업로드에 실패했습니다', + details: error instanceof Error ? error.message : undefined + }, + { status: 500 } + ); + } +} + +// 업로드 진행률 확인 (선택적) +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + // 업로드 상태 확인 로직 (필요시 구현) + return NextResponse.json({ message: '업로드 상태 확인 엔드포인트' }); + } catch (error) { + return NextResponse.json( + { error: '상태 확인 실패' }, + { status: 500 } + ); + } +}
\ No newline at end of file |
