Files
sori.studio/server/utils/multipart-file-upload.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

276 lines
7.5 KiB
JavaScript

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<void>|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<Array<{ filename: string, mimeType: string, size: number, filePath: string, metadata: Object }>>} 저장 결과
*/
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)
})
}