v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,8 +9,9 @@ import {
|
||||
} from 'h3'
|
||||
import { requireAdminSession } from '../../../../../../utils/admin-auth'
|
||||
import { getReadyPostExportFile } from '../../../../../../repositories/post-export-repository'
|
||||
import { getUploadRoot } from '../../../../../../utils/upload-root'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const uploadRoot = getUploadRoot()
|
||||
|
||||
/**
|
||||
* Export 파일 경로를 안전하게 해석한다.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createError } from 'h3'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { importPostsFromExportZip } from '../../../../repositories/post-import-repository'
|
||||
import { saveMultipartFileToDisk } from '../../../../utils/multipart-file-upload'
|
||||
|
||||
const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||
|
||||
@@ -11,43 +15,53 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminSession = requireAdminSession(event)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
||||
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Import할 ZIP 파일을 선택해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!String(file.filename || '').toLowerCase().endsWith('.zip')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'ZIP 파일만 Import할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!file.data?.length) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (file.data.length > MAX_IMPORT_ZIP_BYTES) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'sori-studio-import-'))
|
||||
const zipFilePath = join(tempDirectory, 'upload.zip')
|
||||
|
||||
try {
|
||||
const uploaded = await saveMultipartFileToDisk(event, {
|
||||
fieldName: 'file',
|
||||
filePath: zipFilePath,
|
||||
maxBytes: MAX_IMPORT_ZIP_BYTES
|
||||
})
|
||||
|
||||
if (!String(uploaded.filename || '').toLowerCase().endsWith('.zip')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'ZIP 파일만 Import할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!uploaded.size) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return await importPostsFromExportZip({
|
||||
zipBuffer: file.data,
|
||||
zipFilePath,
|
||||
authorId: adminSession.userId
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error?.code === 'MULTIPART_FILE_TOO_LARGE') {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (['MULTIPART_FILE_NOT_FOUND', 'INVALID_MULTIPART_REQUEST'].includes(error?.code)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Import할 ZIP 파일을 선택해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (error?.message === 'IMPORT_MARKDOWN_NOT_FOUND') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
@@ -62,6 +76,30 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if ([
|
||||
'ZIP_ENTRY_COUNT_LIMIT_EXCEEDED',
|
||||
'ZIP_ENTRY_SIZE_LIMIT_EXCEEDED',
|
||||
'ZIP_TOTAL_SIZE_LIMIT_EXCEEDED',
|
||||
'ZIP_COMPRESSION_RATIO_LIMIT_EXCEEDED',
|
||||
'IMPORT_MARKDOWN_SIZE_LIMIT_EXCEEDED'
|
||||
].includes(error?.message)) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'ZIP 내부 파일의 개수·압축률 또는 압축 해제 크기가 허용 범위를 초과했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if ([
|
||||
'INVALID_ZIP_FILE',
|
||||
'UNSAFE_ZIP_ENTRY_PATH',
|
||||
'DUPLICATE_ZIP_ENTRY_PATH'
|
||||
].includes(error?.message)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '안전하게 처리할 수 없는 ZIP 파일입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (error?.code === '23505') {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
@@ -73,5 +111,10 @@ export default defineEventHandler(async (event) => {
|
||||
statusCode: 500,
|
||||
message: '게시물 Import를 완료하지 못했습니다.'
|
||||
})
|
||||
} finally {
|
||||
await rm(tempDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user