게시물 가져오기 중복 복원 방지
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
@@ -31,6 +32,21 @@ const sanitizeFilenameSegment = (value) => String(value || '')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 80) || 'asset'
|
||||
|
||||
/**
|
||||
* 읽기 스트림의 SHA-256 해시를 만든다.
|
||||
* @param {NodeJS.ReadableStream} stream - 읽기 스트림
|
||||
* @returns {Promise<string>} 해시 문자열
|
||||
*/
|
||||
const createStreamHash = async (stream) => {
|
||||
const hash = createHash('sha256')
|
||||
|
||||
for await (const chunk of stream) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬러그에 안전한 문자열로 정리한다.
|
||||
* @param {string} value - 원본 슬러그
|
||||
@@ -330,21 +346,65 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
|
||||
throw new Error('IMPORT_ASSET_FILENAME_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 저장된 같은 자산 파일을 찾는다.
|
||||
* @param {string} directoryPath - 저장 디렉터리
|
||||
* @param {string} originalName - 원본 파일명
|
||||
* @param {{ uncompressedSize: number, createReadStream: () => NodeJS.ReadableStream }} entry - ZIP 엔트리
|
||||
* @returns {Promise<{ fileName: string, filePath: string }|null>} 재사용 파일
|
||||
*/
|
||||
const findReusableDiskAsset = async (directoryPath, originalName, entry) => {
|
||||
const extension = extname(originalName || '') || '.bin'
|
||||
const stem = sanitizeFilenameSegment(String(originalName || '').replace(/\.[^.]+$/g, '')) || 'asset'
|
||||
const entrySize = Number(entry.uncompressedSize || 0)
|
||||
let entryHash = ''
|
||||
|
||||
for (let suffix = 1; suffix < 10000; suffix += 1) {
|
||||
const fileName = suffix === 1 ? `${stem}${extension}` : `${stem}-${suffix}${extension}`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
let fileStat = null
|
||||
|
||||
try {
|
||||
fileStat = await stat(filePath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (entrySize > 0 && Number(fileStat.size || 0) !== entrySize) {
|
||||
continue
|
||||
}
|
||||
|
||||
entryHash ||= await createStreamHash(entry.createReadStream())
|
||||
|
||||
if (await createStreamHash(createReadStream(filePath)) === entryHash) {
|
||||
return {
|
||||
fileName,
|
||||
filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Import 자산을 업로드 폴더에 저장한다.
|
||||
* @param {Object} input - 저장 입력
|
||||
* @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> }>} 저장 결과
|
||||
* @param {Map<string, string>} input.savedAssetUrls - ZIP 엔트리별 저장 URL 캐시
|
||||
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string>, savedCount: number, reusedCount: number }>} 저장 결과
|
||||
*/
|
||||
const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
const saveImportAssets = async ({ entryMap, postFolder, assetPaths, savedAssetUrls }) => {
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const directoryPath = join(getUploadRoot(), 'posts', year, month)
|
||||
const replacements = new Map()
|
||||
const missingAssets = []
|
||||
let savedCount = 0
|
||||
let reusedCount = 0
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
@@ -357,6 +417,28 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
continue
|
||||
}
|
||||
|
||||
const cachedUrl = savedAssetUrls.get(entryPath)
|
||||
|
||||
if (cachedUrl) {
|
||||
replacements.set(assetPath, cachedUrl)
|
||||
replacements.set(assetPath.replace(/^\.\//, ''), cachedUrl)
|
||||
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, cachedUrl)
|
||||
reusedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const reusableAsset = await findReusableDiskAsset(directoryPath, basename(entryPath), entry)
|
||||
|
||||
if (reusableAsset) {
|
||||
const publicUrl = `${UPLOAD_BASE_URL}/posts/${year}/${month}/${reusableAsset.fileName}`
|
||||
savedAssetUrls.set(entryPath, publicUrl)
|
||||
replacements.set(assetPath, publicUrl)
|
||||
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
|
||||
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
|
||||
reusedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, basename(entryPath))
|
||||
try {
|
||||
await pipeline(
|
||||
@@ -377,14 +459,18 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
origin: 'import'
|
||||
})
|
||||
|
||||
savedAssetUrls.set(entryPath, publicUrl)
|
||||
replacements.set(assetPath, publicUrl)
|
||||
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
|
||||
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
|
||||
savedCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
replacements,
|
||||
missingAssets
|
||||
missingAssets,
|
||||
savedCount,
|
||||
reusedCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,45 +518,42 @@ const collectImportAssetPaths = ({ frontmatter, content }) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 슬러그 중복을 피한다.
|
||||
* @param {string} baseSlug - 기준 슬러그
|
||||
* @param {Set<string>} reservedSlugs - 이번 Import에서 예약된 슬러그
|
||||
* @returns {Promise<string>} 고유 슬러그
|
||||
* 게시물 슬러그가 이미 존재하는지 확인한다.
|
||||
* @param {string} slug - 게시물 슬러그
|
||||
* @returns {Promise<boolean>} 존재 여부
|
||||
*/
|
||||
const createUniquePostSlug = async (baseSlug, reservedSlugs) => {
|
||||
const postSlugExists = async (slug) => {
|
||||
const sql = getPostgresClient()
|
||||
const base = sanitizeSlug(baseSlug)
|
||||
let next = base
|
||||
let suffix = 2
|
||||
|
||||
while (reservedSlugs.has(next)) {
|
||||
next = `${base}-${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
|
||||
if (!sql) {
|
||||
reservedSlugs.add(next)
|
||||
return next
|
||||
return false
|
||||
}
|
||||
|
||||
while (suffix < 10000) {
|
||||
const rows = await sql`
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = ${next}
|
||||
LIMIT 1
|
||||
`
|
||||
const rows = await sql`
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = ${slug}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
if (!rows.length && !reservedSlugs.has(next)) {
|
||||
reservedSlugs.add(next)
|
||||
return next
|
||||
}
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
next = `${base}-${suffix}`
|
||||
suffix += 1
|
||||
/**
|
||||
* Import 게시물 슬러그를 정리한다.
|
||||
* @param {string} baseSlug - 기준 슬러그
|
||||
* @param {Set<string>} reservedSlugs - 이번 Import에서 예약된 슬러그
|
||||
* @returns {{ slug: string, duplicateInZip: boolean }} 정리된 슬러그
|
||||
*/
|
||||
const reserveImportPostSlug = (baseSlug, reservedSlugs) => {
|
||||
const slug = sanitizeSlug(baseSlug)
|
||||
const duplicateInZip = reservedSlugs.has(slug)
|
||||
reservedSlugs.add(slug)
|
||||
|
||||
return {
|
||||
slug,
|
||||
duplicateInZip
|
||||
}
|
||||
|
||||
throw new Error('IMPORT_SLUG_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -567,7 +650,7 @@ const collectMarkdownPosts = async (entries) => {
|
||||
/**
|
||||
* Export ZIP을 게시물로 가져온다.
|
||||
* @param {{ zipFilePath: string, authorId: string }} input - Import 입력
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
|
||||
* @returns {Promise<{ importedCount: number, skippedCount: number, assetCount: number, reusedAssetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object>, skippedPosts: Array<Object> }>} Import 결과
|
||||
*/
|
||||
export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
|
||||
const entries = await openZipFileEntries(zipFilePath, {
|
||||
@@ -584,25 +667,55 @@ export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
|
||||
}
|
||||
|
||||
const reservedSlugs = new Set()
|
||||
const savedAssetUrls = new Map()
|
||||
const importedPosts = []
|
||||
const skippedPosts = []
|
||||
const warnings = []
|
||||
let importedAssetCount = 0
|
||||
let reusedAssetCount = 0
|
||||
|
||||
for (const markdownPost of markdownPosts) {
|
||||
const { frontmatter, content, postFolder } = markdownPost
|
||||
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
|
||||
const { slug, duplicateInZip } = reserveImportPostSlug(frontmatter.slug || title, reservedSlugs)
|
||||
|
||||
if (duplicateInZip) {
|
||||
skippedPosts.push({
|
||||
title,
|
||||
slug,
|
||||
reason: 'duplicate-in-zip'
|
||||
})
|
||||
warnings.push(`${markdownPost.path}: ZIP 안에 같은 slug가 있어 건너뛰었습니다. (${slug})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (await postSlugExists(slug)) {
|
||||
skippedPosts.push({
|
||||
title,
|
||||
slug,
|
||||
reason: 'existing-slug'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const assetPaths = collectImportAssetPaths({ frontmatter, content })
|
||||
const { replacements, missingAssets } = await saveImportAssets({
|
||||
const {
|
||||
replacements,
|
||||
missingAssets,
|
||||
savedCount,
|
||||
reusedCount
|
||||
} = await saveImportAssets({
|
||||
entryMap,
|
||||
postFolder,
|
||||
assetPaths
|
||||
assetPaths,
|
||||
savedAssetUrls
|
||||
})
|
||||
importedAssetCount += replacements.size ? new Set(replacements.values()).size : 0
|
||||
importedAssetCount += savedCount
|
||||
reusedAssetCount += reusedCount
|
||||
missingAssets.forEach((assetPath) => {
|
||||
warnings.push(`${markdownPost.path}: ZIP 내부 자산을 찾지 못했습니다. (${assetPath})`)
|
||||
})
|
||||
|
||||
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
|
||||
const slug = await createUniquePostSlug(frontmatter.slug || title, reservedSlugs)
|
||||
const featuredImage = resolveImportedImageUrl(frontmatter.featured_image, replacements)
|
||||
const ogImage = resolveImportedImageUrl(frontmatter.og_image, replacements)
|
||||
const status = normalizePostStatus(frontmatter.status)
|
||||
@@ -610,36 +723,54 @@ export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
|
||||
? normalizeIsoDate(frontmatter.published_at) || new Date().toISOString()
|
||||
: normalizeIsoDate(frontmatter.published_at)
|
||||
|
||||
const post = await createAdminPost({
|
||||
title,
|
||||
slug,
|
||||
content: replaceAssetPaths(content, replacements),
|
||||
excerpt: String(frontmatter.excerpt || ''),
|
||||
featuredImage,
|
||||
showFeaturedImage: Boolean(frontmatter.show_featured_image),
|
||||
isFeatured: false,
|
||||
seoTitle: String(frontmatter.seo_title || ''),
|
||||
seoDescription: String(frontmatter.seo_description || ''),
|
||||
canonicalUrl: String(frontmatter.canonical_url || ''),
|
||||
noindex: Boolean(frontmatter.noindex),
|
||||
ogImage,
|
||||
status,
|
||||
publishedAt,
|
||||
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : []
|
||||
}, authorId)
|
||||
let post
|
||||
|
||||
try {
|
||||
post = await createAdminPost({
|
||||
title,
|
||||
slug,
|
||||
content: replaceAssetPaths(content, replacements),
|
||||
excerpt: String(frontmatter.excerpt || ''),
|
||||
featuredImage,
|
||||
showFeaturedImage: Boolean(frontmatter.show_featured_image),
|
||||
isFeatured: false,
|
||||
seoTitle: String(frontmatter.seo_title || ''),
|
||||
seoDescription: String(frontmatter.seo_description || ''),
|
||||
canonicalUrl: String(frontmatter.canonical_url || ''),
|
||||
noindex: Boolean(frontmatter.noindex),
|
||||
ogImage,
|
||||
status,
|
||||
publishedAt,
|
||||
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : []
|
||||
}, authorId)
|
||||
} catch (error) {
|
||||
if (error?.code === '23505') {
|
||||
skippedPosts.push({
|
||||
title,
|
||||
slug,
|
||||
reason: 'existing-slug'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
importedPosts.push(post)
|
||||
}
|
||||
|
||||
return {
|
||||
importedCount: importedPosts.length,
|
||||
skippedCount: skippedPosts.length,
|
||||
assetCount: importedAssetCount,
|
||||
reusedAssetCount,
|
||||
warningCount: warnings.length,
|
||||
warnings: warnings.slice(0, 20),
|
||||
posts: importedPosts.map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug
|
||||
}))
|
||||
})),
|
||||
skippedPosts: skippedPosts.slice(0, 20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||
/**
|
||||
* 관리자 게시물 Import API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
|
||||
* @returns {Promise<{ importedCount: number, skippedCount: number, assetCount: number, reusedAssetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object>, skippedPosts: Array<Object> }>} Import 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminSession = requireAdminSession(event)
|
||||
|
||||
Reference in New Issue
Block a user