세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
276 lines
7.5 KiB
JavaScript
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)
|
|
})
|
|
}
|