v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
275
server/utils/multipart-file-upload.js
Normal file
275
server/utils/multipart-file-upload.js
Normal file
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user