v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { readZipBufferEntries } from '../utils/zip-reader'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { openZipFileEntries } from '../utils/zip-reader'
|
||||
import { upsertMediaMetadataCategory } from '../utils/media-library'
|
||||
import { getUploadRoot } from '../utils/upload-root'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
import { createAdminPost } from './content-repository'
|
||||
|
||||
const UPLOAD_BASE_URL = '/uploads'
|
||||
const MAX_IMPORT_POSTS = 1000
|
||||
const MAX_IMPORT_ZIP_ENTRY_COUNT = 5000
|
||||
const MAX_IMPORT_ENTRY_BYTES = 300 * 1024 * 1024
|
||||
const MAX_IMPORT_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
||||
const MAX_IMPORT_COMPRESSION_RATIO = 250
|
||||
const MAX_IMPORT_MARKDOWN_BYTES = 5 * 1024 * 1024
|
||||
const MAX_IMPORT_TOTAL_MARKDOWN_BYTES = 50 * 1024 * 1024
|
||||
const MARKDOWN_EXTENSION_PATTERN = /\.md$/i
|
||||
|
||||
/**
|
||||
@@ -252,10 +261,10 @@ const parseMarkdownDocument = (markdown) => {
|
||||
|
||||
/**
|
||||
* ZIP 엔트리를 경로 기준 Map으로 만든다.
|
||||
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
|
||||
* @returns {Map<string, Buffer>} 엔트리 맵
|
||||
* @param {Array<{ path: string }>} entries - ZIP 엔트리
|
||||
* @returns {Map<string, Object>} 엔트리 맵
|
||||
*/
|
||||
const createZipEntryMap = (entries) => new Map(entries.map((entry) => [entry.path, entry.data]))
|
||||
const createZipEntryMap = (entries) => new Map(entries.map((entry) => [entry.path, entry]))
|
||||
|
||||
/**
|
||||
* Markdown 파일의 상위 폴더를 조회한다.
|
||||
@@ -324,7 +333,7 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
|
||||
/**
|
||||
* Import 자산을 업로드 폴더에 저장한다.
|
||||
* @param {Object} input - 저장 입력
|
||||
* @param {Map<string, Buffer>} input.entryMap - ZIP 엔트리 맵
|
||||
* @param {Map<string, Object>} input.entryMap - ZIP 엔트리 맵
|
||||
* @param {string} input.postFolder - 게시물 폴더
|
||||
* @param {Set<string>} input.assetPaths - 자산 경로 목록
|
||||
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string> }>} 저장 결과
|
||||
@@ -333,7 +342,7 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const directoryPath = join(process.cwd(), 'public', 'uploads', 'posts', year, month)
|
||||
const directoryPath = join(getUploadRoot(), 'posts', year, month)
|
||||
const replacements = new Map()
|
||||
const missingAssets = []
|
||||
|
||||
@@ -341,18 +350,32 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
|
||||
for (const assetPath of assetPaths) {
|
||||
const entryPath = resolveAssetEntryPath(postFolder, assetPath)
|
||||
const data = entryMap.get(entryPath)
|
||||
const entry = entryMap.get(entryPath)
|
||||
|
||||
if (!data) {
|
||||
if (!entry) {
|
||||
missingAssets.push(assetPath)
|
||||
continue
|
||||
}
|
||||
|
||||
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, basename(entryPath))
|
||||
await writeFile(filePath, data)
|
||||
try {
|
||||
await pipeline(
|
||||
entry.createReadStream(),
|
||||
createWriteStream(filePath, {
|
||||
flags: 'wx'
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
await rm(filePath, {
|
||||
force: true
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
const publicUrl = `${UPLOAD_BASE_URL}/posts/${year}/${month}/${fileName}`
|
||||
await upsertMediaMetadataCategory(publicUrl, '미분류')
|
||||
await upsertMediaMetadataCategory(publicUrl, '미분류', {
|
||||
origin: 'import'
|
||||
})
|
||||
|
||||
replacements.set(assetPath, publicUrl)
|
||||
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
|
||||
@@ -511,35 +534,55 @@ const normalizeIsoDate = (value) => {
|
||||
|
||||
/**
|
||||
* ZIP 엔트리에서 Markdown 게시물 목록을 만든다.
|
||||
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
|
||||
* @returns {Array<Object>} Markdown 문서 목록
|
||||
* @param {Array<{ path: string, uncompressedSize: number, readBuffer: (maxBytes?: number) => Promise<Buffer> }>} entries - ZIP 엔트리
|
||||
* @returns {Promise<Array<Object>>} Markdown 문서 목록
|
||||
*/
|
||||
const collectMarkdownPosts = (entries) => entries
|
||||
.filter((entry) => MARKDOWN_EXTENSION_PATTERN.test(entry.path))
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
postFolder: getPostFolder(entry.path),
|
||||
...parseMarkdownDocument(entry.data.toString('utf8'))
|
||||
}))
|
||||
const collectMarkdownPosts = async (entries) => {
|
||||
const markdownEntries = entries.filter((entry) => MARKDOWN_EXTENSION_PATTERN.test(entry.path))
|
||||
|
||||
if (markdownEntries.length > MAX_IMPORT_POSTS) {
|
||||
throw new Error('IMPORT_POST_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
const markdownPosts = []
|
||||
let totalMarkdownBytes = 0
|
||||
|
||||
for (const entry of markdownEntries) {
|
||||
totalMarkdownBytes += Number(entry.uncompressedSize || 0)
|
||||
if (totalMarkdownBytes > MAX_IMPORT_TOTAL_MARKDOWN_BYTES) {
|
||||
throw new Error('IMPORT_MARKDOWN_SIZE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
const data = await entry.readBuffer(MAX_IMPORT_MARKDOWN_BYTES)
|
||||
markdownPosts.push({
|
||||
path: entry.path,
|
||||
postFolder: getPostFolder(entry.path),
|
||||
...parseMarkdownDocument(data.toString('utf8'))
|
||||
})
|
||||
}
|
||||
|
||||
return markdownPosts
|
||||
}
|
||||
|
||||
/**
|
||||
* Export ZIP을 게시물로 가져온다.
|
||||
* @param {{ zipBuffer: Buffer, authorId: string }} input - Import 입력
|
||||
* @param {{ zipFilePath: string, authorId: string }} input - Import 입력
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
|
||||
*/
|
||||
export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
|
||||
const entries = readZipBufferEntries(zipBuffer)
|
||||
export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
|
||||
const entries = await openZipFileEntries(zipFilePath, {
|
||||
maxEntryCount: MAX_IMPORT_ZIP_ENTRY_COUNT,
|
||||
maxEntryBytes: MAX_IMPORT_ENTRY_BYTES,
|
||||
maxTotalBytes: MAX_IMPORT_TOTAL_BYTES,
|
||||
maxCompressionRatio: MAX_IMPORT_COMPRESSION_RATIO
|
||||
})
|
||||
const entryMap = createZipEntryMap(entries)
|
||||
const markdownPosts = collectMarkdownPosts(entries)
|
||||
const markdownPosts = await collectMarkdownPosts(entries)
|
||||
|
||||
if (!markdownPosts.length) {
|
||||
throw new Error('IMPORT_MARKDOWN_NOT_FOUND')
|
||||
}
|
||||
|
||||
if (markdownPosts.length > MAX_IMPORT_POSTS) {
|
||||
throw new Error('IMPORT_POST_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
const reservedSlugs = new Set()
|
||||
const importedPosts = []
|
||||
const warnings = []
|
||||
|
||||
Reference in New Issue
Block a user