import { createWriteStream } from 'node:fs' import { Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' import Busboy from 'busboy' /** * 코드가 포함된 multipart 오류를 만든다. * @param {string} code - 오류 코드 * @returns {Error & { code: string }} multipart 오류 */ const createMultipartError = (code) => { const error = new Error(code) error.code = code return error } /** * busboy가 UTF-8 파일명 바이트를 Latin-1 문자열로 넘긴 경우를 복구한다. * 이미 유니코드 코드포인트로 해석된 파일명은 그대로 둔다. * @param {string|null|undefined} filename - multipart 파일명 * @returns {string} NFC 정규화된 파일명 */ export const decodeMultipartFilename = (filename) => { const value = String(filename || '') if (!value) { return '' } const hasNonLatin1 = [...value].some((char) => Number(char.codePointAt(0)) > 255) if (hasNonLatin1) { return value.normalize('NFC') } const repaired = Buffer.from(value, 'latin1').toString('utf8') if (!repaired.includes('\uFFFD')) { return repaired.normalize('NFC') } return value.normalize('NFC') } /** * multipart 파일 하나를 메모리에 올리지 않고 디스크로 저장한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {{ fieldName: string, filePath: string, maxBytes: number }} input - 저장 옵션 * @returns {Promise<{ filename: string, mimeType: string, size: number }>} 저장 결과 */ export const saveMultipartFileToDisk = async (event, input) => { return new Promise((resolve, reject) => { let parser = null try { parser = Busboy({ headers: event.node.req.headers, limits: { files: 1, fields: 4, parts: 5, fileSize: input.maxBytes } }) } catch { reject(createMultipartError('INVALID_MULTIPART_REQUEST')) return } let matched = false let truncated = false let filename = '' let mimeType = '' let size = 0 /** @type {Promise|null} */ let fileWritePromise = null parser.on('file', (fieldName, fileStream, info) => { if (fieldName !== input.fieldName || matched) { fileStream.resume() return } matched = true filename = decodeMultipartFilename(info.filename) mimeType = String(info.mimeType || '') const output = createWriteStream(input.filePath, { flags: 'wx' }) fileStream.on('data', (chunk) => { size += chunk.length }) fileStream.on('limit', () => { truncated = true }) fileWritePromise = pipeline(fileStream, output) }) parser.on('filesLimit', () => { truncated = true }) parser.on('error', async (error) => { if (fileWritePromise) { await fileWritePromise.catch(() => {}) } reject(error) }) parser.on('finish', async () => { try { if (fileWritePromise) { await fileWritePromise } if (!matched) { reject(createMultipartError('MULTIPART_FILE_NOT_FOUND')) return } if (truncated) { reject(createMultipartError('MULTIPART_FILE_TOO_LARGE')) return } resolve({ filename, mimeType, size }) } catch (error) { reject(error) } }) event.node.req.on('aborted', () => { parser.destroy(createMultipartError('MULTIPART_REQUEST_ABORTED')) }) event.node.req.pipe(parser) }) } /** * multipart 파일 여러 개를 메모리에 올리지 않고 디스크로 저장한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {{ * fieldName: string, * maxFiles: number, * maxTotalBytes: number, * createFileTarget: (file: { filename: string, mimeType: string }, index: number) => Promise<{ filePath: string, maxBytes: number, metadata?: Object }> * }} input - 저장 옵션 * @returns {Promise>} 저장 결과 */ export const saveMultipartFilesToDisk = async (event, input) => { return new Promise((resolve, reject) => { let parser = null try { parser = Busboy({ headers: event.node.req.headers, limits: { files: input.maxFiles, fields: 4, parts: input.maxFiles + 4, fileSize: input.maxTotalBytes } }) } catch { reject(createMultipartError('INVALID_MULTIPART_REQUEST')) return } let matchedFileCount = 0 let totalSize = 0 let limitError = null let taskError = null const savedFiles = [] const fileTasks = [] parser.on('file', (fieldName, fileStream, info) => { if (fieldName !== input.fieldName) { fileStream.resume() return } const fileIndex = matchedFileCount matchedFileCount += 1 const filename = decodeMultipartFilename(info.filename) const mimeType = String(info.mimeType || '') const fileTask = (async () => { try { const target = await input.createFileTarget({ filename, mimeType }, fileIndex) let fileSize = 0 const sizeLimiter = new Transform({ transform(chunk, _encoding, callback) { fileSize += chunk.length totalSize += chunk.length if (!limitError && fileSize > target.maxBytes) { limitError = createMultipartError('MULTIPART_FILE_TOO_LARGE') limitError.maxBytes = target.maxBytes limitError.metadata = target.metadata || {} } if (!limitError && totalSize > input.maxTotalBytes) { limitError = createMultipartError('MULTIPART_TOTAL_SIZE_TOO_LARGE') } callback(null, limitError ? null : chunk) } }) fileStream.on('limit', () => { limitError = limitError || createMultipartError('MULTIPART_TOTAL_SIZE_TOO_LARGE') }) await pipeline( fileStream, sizeLimiter, createWriteStream(target.filePath, { flags: 'wx' }) ) savedFiles.push({ filename, mimeType, size: fileSize, filePath: target.filePath, metadata: target.metadata || {}, index: fileIndex }) } catch (error) { taskError = taskError || error fileStream.resume() } })() fileTasks.push(fileTask) }) parser.on('filesLimit', () => { limitError = limitError || createMultipartError('MULTIPART_TOO_MANY_FILES') }) parser.on('partsLimit', () => { limitError = limitError || createMultipartError('MULTIPART_TOO_MANY_PARTS') }) parser.on('error', async (error) => { await Promise.allSettled(fileTasks) reject(error) }) parser.on('finish', async () => { await Promise.all(fileTasks) if (taskError) { reject(taskError) return } if (limitError) { reject(limitError) return } if (!matchedFileCount) { reject(createMultipartError('MULTIPART_FILE_NOT_FOUND')) return } resolve(savedFiles .sort((left, right) => left.index - right.index) .map(({ index: _index, ...file }) => file)) }) event.node.req.on('aborted', () => { parser.destroy(createMultipartError('MULTIPART_REQUEST_ABORTED')) }) event.node.req.pipe(parser) }) }