diff options
Diffstat (limited to 'app/api/swp')
| -rw-r--r-- | app/api/swp/upload/route.ts | 299 |
1 files changed, 243 insertions, 56 deletions
diff --git a/app/api/swp/upload/route.ts b/app/api/swp/upload/route.ts index 71c88cec..350678a7 100644 --- a/app/api/swp/upload/route.ts +++ b/app/api/swp/upload/route.ts @@ -4,11 +4,83 @@ import * as path from "path"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils"; +import formidable, { Fields, Files, File as FormidableFile } from "formidable"; +import { Readable } from "stream"; // API Route 설정 export const runtime = "nodejs"; export const maxDuration = 3600; // 1시간 타임아웃 (대용량 파일 업로드 대응) +// Next.js 15 API Route body parsing 비활성화 (스트리밍 처리를 위해) +export const dynamic = 'force-dynamic'; + +/** + * formidable을 사용하여 스트리밍 방식으로 파일 파싱 + * 메모리에 전체 파일을 올리지 않고 chunk 단위로 처리 + */ +async function parseFormWithFormidable(req: NextRequest): Promise<{ fields: Fields; files: Files }> { + const uploadDir = process.env.TEMP_UPLOAD_DIR || "/tmp/swp-upload"; + + const form = formidable({ + maxFileSize: 1024 * 1024 * 1024, // 1GB 제한 + maxFieldsSize: 20 * 1024 * 1024, // 20MB 메타데이터 제한 + allowEmptyFiles: false, + multiples: true, // 여러 파일 업로드 허용 + keepExtensions: true, + // 임시 디렉토리에 파일 저장 (스트리밍 방식) + uploadDir, + filename: (_name, _ext, part) => { + // 원본 파일명 유지 + return part.originalFilename || `upload_${Date.now()}`; + }, + }); + + // 임시 디렉토리 생성 + await fs.mkdir(uploadDir, { recursive: true }); + + // Next.js Request를 Node.js IncomingMessage 형태로 변환 + // formidable은 headers 정보가 필요함 (특히 content-length, content-type) + + // ReadableStream을 AsyncIterable로 변환 + async function* streamToAsyncIterable(stream: ReadableStream<Uint8Array>) { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + } + } finally { + reader.releaseLock(); + } + } + + const bodyStream = req.body + ? Readable.from(streamToAsyncIterable(req.body as ReadableStream<Uint8Array>)) + : Readable.from([]); + + // headers 정보 추가 (formidable이 필요로 함) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockIncomingMessage: any = Object.assign(bodyStream, { + headers: { + 'content-type': req.headers.get('content-type') || '', + 'content-length': req.headers.get('content-length') || '0', + }, + method: req.method, + url: req.url, + }); + + return new Promise((resolve, reject) => { + form.parse(mockIncomingMessage, (err, fields, files) => { + if (err) { + reject(err); + return; + } + resolve({ fields, files }); + }); + }); +} + interface InBoxFileInfo { CPY_CD: string; FILE_NM: string; @@ -27,6 +99,11 @@ interface InBoxFileInfo { * 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음 */ function parseFileName(fileName: string) { + // 경로 순회 공격 방지 (Path Traversal Attack) + if (fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) { + throw new Error(`잘못된 파일명입니다 (경로 문자 포함): ${fileName}`); + } + const lastDotIndex = fileName.lastIndexOf("."); // 확장자 검증 @@ -92,6 +169,26 @@ function generateTimestamp(): string { return `${year}${month}${day}${hours}${minutes}${seconds}`; } +/** + * 디스크 공간 검사 (간단한 휴리스틱 방식) + * 디렉토리에 쓰기 권한이 있는지 확인 + * + * 참고: 실제 디스크 공간 체크를 위해서는 'check-disk-space' 라이브러리 사용으로 변경하기 + */ +async function checkDiskWritable(directory: string): Promise<boolean> { + try { + // 테스트 쓰기로 권한 검증 + await fs.mkdir(directory, { recursive: true }); + const testFile = path.join(directory, `.disk-check-${Date.now()}`); + await fs.writeFile(testFile, "test"); + await fs.unlink(testFile); + return true; + } catch (error) { + console.error(`[checkDiskWritable] 디스크 쓰기 권한 검사 실패: ${error}`); + return false; + } +} + /** * SaveInBoxList API 호출 @@ -156,10 +253,28 @@ async function callSaveInBoxList(fileInfos: InBoxFileInfo[], crter: string): Pro /** * POST /api/swp/upload - * FormData로 파일 업로드 + * 스트리밍 방식으로 파일 업로드 (대용량 파일 지원) */ export async function POST(request: NextRequest) { try { + // 환경 변수 검증 + const swpMountDir = process.env.SWP_MOUNT_DIR; + const ddcBaseUrl = process.env.DDC_BASE_URL; + + if (!swpMountDir) { + return NextResponse.json( + { success: false, message: "서버 설정 오류: SWP_MOUNT_DIR 환경 변수가 설정되지 않았습니다." }, + { status: 500 } + ); + } + + if (!ddcBaseUrl) { + return NextResponse.json( + { success: false, message: "서버 설정 오류: DDC_BASE_URL 환경 변수가 설정되지 않았습니다." }, + { status: 500 } + ); + } + // 세션에서 사용자 ID 가져오기 const session = await getServerSession(authOptions); @@ -173,10 +288,17 @@ export async function POST(request: NextRequest) { const crter = String(session.user.id); // 사용자 ID를 문자열로 변환 console.log(`[upload] 사용자 ID (crter): ${crter}`); - const formData = await request.formData(); + // 스트리밍 방식으로 FormData 파싱 + debugLog("[upload] 스트리밍 파싱 시작..."); + const { fields, files } = await parseFormWithFormidable(request); + debugSuccess("[upload] 스트리밍 파싱 완료"); + + // 필드 추출 + const projNoArray = fields.projNo; + const vndrCdArray = fields.vndrCd; - const projNo = formData.get("projNo") as string; - const vndrCd = formData.get("vndrCd") as string; + const projNo = Array.isArray(projNoArray) ? projNoArray[0] : projNoArray; + const vndrCd = Array.isArray(vndrCdArray) ? vndrCdArray[0] : vndrCdArray; if (!projNo || !vndrCd) { return NextResponse.json( @@ -188,15 +310,19 @@ export async function POST(request: NextRequest) { // vndrCd를 CPY_CD로 사용 console.log(`[upload] vndrCd를 CPY_CD로 사용: ${vndrCd}`); - const files = formData.getAll("files") as File[]; + // 파일 배열 추출 (formidable은 files.files로 저장) + const uploadedFiles = files.files; - if (!files || files.length === 0) { + if (!uploadedFiles || (Array.isArray(uploadedFiles) && uploadedFiles.length === 0)) { return NextResponse.json( { success: false, message: "업로드할 파일이 없습니다." }, { status: 400 } ); } + // 단일 파일인 경우 배열로 변환 + const fileArray = Array.isArray(uploadedFiles) ? uploadedFiles : [uploadedFiles]; + const result = { successCount: 0, failedCount: 0, @@ -204,31 +330,51 @@ export async function POST(request: NextRequest) { }; const inBoxFileInfos: InBoxFileInfo[] = []; - const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/"; // 업로드 시점의 timestamp 생성 (모든 파일에 동일한 timestamp 사용) const uploadTimestamp = generateTimestamp(); console.log(`[upload] 업로드 타임스탬프 생성: ${uploadTimestamp}`); + + // 디스크 쓰기 권한 사전 검사 + const targetDirectory = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp); + const isWritable = await checkDiskWritable(targetDirectory); + if (!isWritable) { + return NextResponse.json( + { success: false, message: "파일 저장 경로에 쓰기 권한이 없습니다." }, + { status: 500 } + ); + } - for (const file of files) { + // 임시 파일 경로 저장 (정리용) + const tempFilesToClean: string[] = []; + // 성공적으로 저장된 파일 경로 (롤백용) + const savedFiles: string[] = []; + + for (const file of fileArray) { try { + const formidableFile = file as FormidableFile; + const originalFileName = formidableFile.originalFilename || "unknown"; + // 파일명 파싱 - const parsed = parseFileName(file.name); - console.log(`[upload] 파일명 파싱:`, parsed); + const parsed = parseFileName(originalFileName); + debugLog(`[upload] 파일명 파싱:`, { originalFileName, parsed }); // 네트워크 경로 생성 (timestamp를 경로에만 사용) - const networkPath = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp, file.name); + const networkPath = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp, originalFileName); // 파일 중복 체크 try { await fs.access(networkPath, fs.constants.F_OK); result.failedCount++; result.details.push({ - fileName: file.name, + fileName: originalFileName, success: false, error: "파일이 이미 존재합니다.", }); console.warn(`[upload] 파일 중복: ${networkPath}`); + + // 임시 파일 정리 + tempFilesToClean.push(formidableFile.filepath); continue; } catch { // 파일이 존재하지 않음 (정상) @@ -238,92 +384,133 @@ export async function POST(request: NextRequest) { const directory = path.dirname(networkPath); await fs.mkdir(directory, { recursive: true }); - // 파일 저장 (스트리밍 방식) - const arrayBuffer = await file.arrayBuffer(); - debugLog(`[upload] ArrayBuffer 변환 완료: ${file.name}`, { - arrayBufferSize: arrayBuffer.byteLength, - fileType: file.type, - originalSize: file.size - }); + // 🚀 스트리밍 방식: 임시 파일을 최종 경로로 이동 (메모리 복사 없음) + const tempFilePath = formidableFile.filepath; + const fileSize = formidableFile.size; - const buffer = Buffer.from(arrayBuffer); - debugLog(`[upload] Buffer 생성 완료: ${file.name}`, { - bufferLength: buffer.length, - bufferType: typeof buffer, - isBuffer: Buffer.isBuffer(buffer), - first20Bytes: buffer.slice(0, 20).toString('hex') + debugLog(`[upload] 파일 이동 시작: ${originalFileName}`, { + tempPath: tempFilePath, + finalPath: networkPath, + size: fileSize }); - console.log(`[upload] 파일 저장: ${file.name} (${buffer.length} bytes)`); - - // 저장 전 buffer 상태 확인 - debugLog(`[upload] 저장 직전 buffer 상태`, { - constructor: buffer.constructor.name, - isBuffer: Buffer.isBuffer(buffer), - jsonStringified: JSON.stringify(buffer).substring(0, 100) + '...' - }); - - await fs.writeFile(networkPath, buffer); - debugSuccess(`[upload] 파일 저장 완료: ${networkPath}`); + // 파일 이동 (rename이 더 빠르지만, 같은 파일시스템이 아니면 실패하므로 copyFile 사용) + // 리네임은 같은 시스템 안에서, 파일 메타데이터만 변경해서 빠른데 swp는 외부시스템이므로 항상 캐치문에 걸릴것임 + try { + await fs.rename(tempFilePath, networkPath); + debugSuccess(`[upload] 파일 이동 완료 (rename): ${networkPath}`); + } catch (renameError) { + // rename 실패 시 copyFile + unlink 사용 + debugLog(`[upload] rename 실패(네트워크경로 - 정상임), copyFile 사용: ${originalFileName}`); + await fs.copyFile(tempFilePath, networkPath); + tempFilesToClean.push(tempFilePath); + debugSuccess(`[upload] 파일 복사 완료: ${networkPath}`); + } // 저장된 파일 검증 const savedFileStats = await fs.stat(networkPath); - debugLog(`[upload] 저장된 파일 정보`, { - size: savedFileStats.size, - expectedSize: buffer.length, - sizeMatch: savedFileStats.size === buffer.length - }); - - // 저장된 파일 첫 부분 읽어서 검증 - const verifyBuffer = await fs.readFile(networkPath); debugLog(`[upload] 저장된 파일 검증`, { - readSize: verifyBuffer.length, - first20Bytes: verifyBuffer.slice(0, 20).toString('hex'), - isBuffer: Buffer.isBuffer(verifyBuffer), - matchesOriginal: buffer.slice(0, 20).equals(verifyBuffer.slice(0, 20)) + fileName: originalFileName, + savedSize: savedFileStats.size, + expectedSize: fileSize, + sizeMatch: savedFileStats.size === fileSize }); + + if (savedFileStats.size !== fileSize) { + throw new Error(`파일 크기 불일치: 예상 ${fileSize} bytes, 실제 ${savedFileStats.size} bytes`); + } // InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용) + // 시스템 요구사항: 중간 구분자만 이스케이프 (\\\\) const fldPath = `\\${projNo}\\${vndrCd}\\\\${uploadTimestamp}`; inBoxFileInfos.push({ CPY_CD: vndrCd, - FILE_NM: file.name, + FILE_NM: originalFileName, OFDC_NO: null, PROJ_NO: projNo, OWN_DOC_NO: parsed.ownDocNo, REV_NO: parsed.revNo, STAGE: parsed.stage, STAT: "SCW01", - FILE_SZ: String(buffer.length), + FILE_SZ: String(fileSize), FLD_PATH: fldPath, }); + // 성공한 파일 경로 저장 (롤백용) + savedFiles.push(networkPath); + result.successCount++; result.details.push({ - fileName: file.name, + fileName: originalFileName, success: true, networkPath, }); } catch (error) { + const formidableFile = file as FormidableFile; + const originalFileName = formidableFile.originalFilename || "unknown"; + result.failedCount++; result.details.push({ - fileName: file.name, + fileName: originalFileName, success: false, error: error instanceof Error ? error.message : "알 수 없는 오류", }); - console.error(`[upload] 파일 처리 실패: ${file.name}`, error); - debugError(`[upload] 파일 처리 실패: ${file.name}`, { + console.error(`[upload] 파일 처리 실패: ${originalFileName}`, error); + debugError(`[upload] 파일 처리 실패: ${originalFileName}`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); + + // 임시 파일 정리 + if (formidableFile.filepath) { + tempFilesToClean.push(formidableFile.filepath); + } + } + } + + // 임시 파일 정리 + for (const tempFile of tempFilesToClean) { + try { + await fs.unlink(tempFile); + debugLog(`[upload] 임시 파일 삭제: ${tempFile}`); + } catch (cleanError) { + console.warn(`[upload] 임시 파일 삭제 실패 (무시): ${tempFile}`, cleanError); } } - // SaveInBoxList API 호출 + // SaveInBoxList API 호출 (트랜잭션 방식) if (inBoxFileInfos.length > 0) { console.log(`[upload] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`); - await callSaveInBoxList(inBoxFileInfos, crter); + try { + await callSaveInBoxList(inBoxFileInfos, crter); + } catch (apiError) { + // API 호출 실패 시 롤백: 저장된 파일 삭제 + console.error(`[upload] SaveInBoxList API 실패, 롤백 시작...`, apiError); + debugError(`[upload] SaveInBoxList API 실패, 롤백 시작`, { + error: apiError instanceof Error ? apiError.message : String(apiError), + filesCount: savedFiles.length + }); + + for (const filePath of savedFiles) { + try { + await fs.unlink(filePath); + console.log(`[upload] 롤백: 파일 삭제 - ${filePath}`); + } catch (unlinkError) { + console.warn(`[upload] 롤백 중 파일 삭제 실패 (무시): ${filePath}`, unlinkError); + } + } + + // 클라이언트에게 에러 반환 + return NextResponse.json( + { + success: false, + message: `파일 등록 API 호출 실패: ${apiError instanceof Error ? apiError.message : "알 수 없는 오류"}`, + rollback: true, + }, + { status: 500 } + ); + } } // ⚠️ Full API 방식으로 전환했으므로 로컬 DB 동기화는 불필요 |
