diff options
Diffstat (limited to 'app/api/files')
| -rw-r--r-- | app/api/files/[...path]/route.ts | 244 |
1 files changed, 193 insertions, 51 deletions
diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index f92dd1d8..e03187e3 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -1,74 +1,216 @@ // app/api/files/[...path]/route.ts -import { NextRequest, NextResponse } from 'next/server' -import { readFile } from 'fs/promises' -import { join } from 'path' -import { stat } from 'fs/promises' +// /nas_evcp 경로에서 파일을 서빙하는 API (다운로드 강제 기능 추가) + +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; + +const nasPath = process.env.NAS_PATH || "/evcp_nas" + +// MIME 타입 매핑 +const getMimeType = (filePath: string): string => { + const ext = path.extname(filePath).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', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.txt': 'text/plain', + '.zip': 'application/zip', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +}; + +// 보안: 허용된 디렉토리 체크 +const isAllowedPath = (requestedPath: string): boolean => { + const allowedPaths = [ + 'basicContract', + 'basicContract/template', + 'basicContract/signed', + 'vendorFormReportSample', + 'vendorFormData', + ]; + + return allowedPaths.some(allowed => + requestedPath.startsWith(allowed) || requestedPath === allowed + ); +}; export async function GET( request: NextRequest, { params }: { params: { path: string[] } } ) { try { + // 요청된 파일 경로 구성 + const requestedPath = params.path.join('/'); + + console.log(`📂 파일 요청: ${requestedPath}`); + + // ✅ 다운로드 강제 여부 확인 + const url = new URL(request.url); + const forceDownload = url.searchParams.get('download') === 'true'; + + console.log(`📥 다운로드 강제 모드: ${forceDownload}`); + + // 보안 체크: 허용된 경로인지 확인 + if (!isAllowedPath(requestedPath)) { + console.log(`❌ 허용되지 않은 경로: ${requestedPath}`); + return new NextResponse('Forbidden', { status: 403 }); + } + + // 경로 트래버설 공격 방지 + if (requestedPath.includes('..') || requestedPath.includes('~')) { + console.log(`❌ 위험한 경로 패턴: ${requestedPath}`); + return new NextResponse('Bad Request', { status: 400 }); + } - const path = request.nextUrl.searchParams.get("path"); + // 환경에 따른 파일 경로 설정 + let filePath: string; + + if (process.env.NODE_ENV === 'production') { + // ✅ 프로덕션: NAS 경로 사용 + filePath = path.join(nasPath, requestedPath); + } else { + // 개발: public 폴더 + filePath = path.join(process.cwd(), 'public', requestedPath); + } + console.log(`📁 실제 파일 경로: ${filePath}`); - // 경로 파라미터에서 파일 경로 조합 - const filePath = join(process.cwd(), 'uploads', ...params.path) - // 파일 존재 여부 확인 try { - await stat(filePath) - } catch (error) { - return NextResponse.json( - { error: 'File not found' }, - { status: 404 } - ) + await fs.access(filePath); + } catch { + console.log(`❌ 파일 없음: ${filePath}`); + return new NextResponse('File not found', { status: 404 }); } - + + // 파일 통계 정보 가져오기 + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + console.log(`❌ 파일이 아님: ${filePath}`); + return new NextResponse('Not a file', { status: 400 }); + } + // 파일 읽기 - const fileBuffer = await readFile(filePath) + const fileBuffer = await fs.readFile(filePath); - // 파일 확장자에 따른 MIME 타입 설정 - const fileName = params.path[params.path.length - 1] - const fileExtension = fileName.split('.').pop()?.toLowerCase() + // MIME 타입 결정 + const mimeType = getMimeType(filePath); + const fileName = path.basename(filePath); + + console.log(`✅ 파일 서빙 성공: ${fileName} (${stats.size} bytes)`); + + // ✅ Content-Disposition 헤더 결정 + const contentDisposition = forceDownload + ? `attachment; filename="${fileName}"` // 강제 다운로드 + : `inline; filename="${fileName}"`; // 브라우저에서 열기 + + // Range 요청 처리 (큰 파일의 부분 다운로드 지원) + const range = request.headers.get('range'); - let contentType = 'application/octet-stream' + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; + const chunksize = (end - start) + 1; + const chunk = fileBuffer.slice(start, end + 1); + + return new NextResponse(chunk, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${stats.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize.toString(), + 'Content-Type': mimeType, + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + }, + }); + } + + // 일반 파일 응답 + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': stats.size.toString(), + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + 'Cache-Control': 'public, max-age=31536000', // 1년 캐시 + 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, + // ✅ 추가 보안 헤더 + 'X-Content-Type-Options': 'nosniff', + }, + }); + + } catch (error) { + console.error('❌ 파일 서빙 오류:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} + +// HEAD 요청 지원 (파일 정보만 확인) +export async function HEAD( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + try { + const requestedPath = params.path.join('/'); - if (fileExtension) { - 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 + // ✅ HEAD 요청에서도 다운로드 강제 여부 확인 + const url = new URL(request.url); + const forceDownload = url.searchParams.get('download') === 'true'; + + if (!isAllowedPath(requestedPath)) { + return new NextResponse(null, { status: 403 }); } - // 다운로드 설정 - const headers = new Headers() - headers.set('Content-Type', contentType) - headers.set('Content-Disposition', `attachment; filename="${fileName}"`) + if (requestedPath.includes('..') || requestedPath.includes('~')) { + return new NextResponse(null, { status: 400 }); + } + + let filePath: string; - return new NextResponse(fileBuffer, { - status: 200, - headers, - }) + if (process.env.NODE_ENV === 'production') { + filePath = path.join(nasPath, requestedPath); + } else { + filePath = path.join(process.cwd(), 'public', requestedPath); + } + + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + return new NextResponse(null, { status: 400 }); + } + + const mimeType = getMimeType(filePath); + const fileName = path.basename(filePath); + + // ✅ HEAD 요청에서도 Content-Disposition 헤더 적용 + const contentDisposition = forceDownload + ? `attachment; filename="${fileName}"` // 강제 다운로드 + : `inline; filename="${fileName}"`; // 브라우저에서 열기 + + return new NextResponse(null, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': stats.size.toString(), + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + 'Last-Modified': stats.mtime.toUTCString(), + 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch { + return new NextResponse(null, { status: 404 }); + } + } catch (error) { - console.error('Error downloading file:', error) - return NextResponse.json( - { error: 'Failed to download file' }, - { status: 500 } - ) + console.error('File HEAD error:', error); + return new NextResponse(null, { status: 500 }); } }
\ No newline at end of file |
