v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.

세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 11:05:40 +09:00
parent 9bc0f0a90e
commit de767095ba
116 changed files with 8128 additions and 2933 deletions

View File

@@ -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 = []