게시물 Export ZIP 생성 연결 v1.5.22
This commit is contained in:
@@ -1,18 +1,30 @@
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join, relative } from 'node:path'
|
||||
import { createZipBuffer } from '../utils/zip-writer'
|
||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
const EXPORT_STATUS = {
|
||||
QUEUED: 'queued',
|
||||
PROCESSING: 'processing',
|
||||
FAILED: 'failed',
|
||||
READY: 'ready'
|
||||
}
|
||||
|
||||
const EXPORT_FILE_STATUS = {
|
||||
PENDING: 'pending'
|
||||
PENDING: 'pending',
|
||||
PROCESSING: 'processing',
|
||||
READY: 'ready',
|
||||
FAILED: 'failed'
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 100
|
||||
const MAX_CHUNK_SIZE = 500
|
||||
const DEFAULT_RETENTION_DAYS = 100
|
||||
const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads')
|
||||
const EXPORT_ROOT_NAME = 'exports'
|
||||
const runningPostExportJobIds = new Set()
|
||||
const localUploadUrlPattern = /\/uploads\/[^\s"'<>)]*/g
|
||||
|
||||
/**
|
||||
* 파일명에 쓸 수 없는 문자를 정리한다.
|
||||
@@ -27,6 +39,234 @@ const sanitizeFilenameSegment = (value) => String(value || '')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 80) || 'sori.studio'
|
||||
|
||||
/**
|
||||
* 중복되지 않는 ZIP 내부 경로 세그먼트를 만든다.
|
||||
* @param {string} value - 원본 세그먼트
|
||||
* @param {Set<string>} usedSegments - 이미 사용한 세그먼트
|
||||
* @returns {string} 고유 세그먼트
|
||||
*/
|
||||
const createUniqueSegment = (value, usedSegments) => {
|
||||
const base = sanitizeFilenameSegment(value)
|
||||
let next = base
|
||||
let index = 2
|
||||
|
||||
while (usedSegments.has(next)) {
|
||||
next = `${base}-${index}`
|
||||
index += 1
|
||||
}
|
||||
|
||||
usedSegments.add(next)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 내부 파일명 중복을 방지한다.
|
||||
* @param {string} value - 원본 파일명
|
||||
* @param {Set<string>} usedNames - 이미 사용한 파일명
|
||||
* @returns {string} 고유 파일명
|
||||
*/
|
||||
const createUniqueAssetName = (value, usedNames) => {
|
||||
const rawName = sanitizeFilenameSegment(value || 'asset')
|
||||
const extension = extname(rawName)
|
||||
const stem = extension ? rawName.slice(0, -extension.length) : rawName
|
||||
let next = rawName
|
||||
let index = 2
|
||||
|
||||
while (usedNames.has(next)) {
|
||||
next = extension ? `${stem}-${index}${extension}` : `${stem}-${index}`
|
||||
index += 1
|
||||
}
|
||||
|
||||
usedNames.add(next)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML 값에 안전한 따옴표를 적용한다.
|
||||
* @param {unknown} value - 원본 값
|
||||
* @returns {string} YAML 문자열 값
|
||||
*/
|
||||
const quoteYamlValue = (value) => `"${String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
|
||||
/**
|
||||
* YAML 배열을 만든다.
|
||||
* @param {Array<string>} values - 배열 값
|
||||
* @returns {string} YAML 배열
|
||||
*/
|
||||
const formatYamlArray = (values) => {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return '[]'
|
||||
}
|
||||
|
||||
return `[${values.map((value) => quoteYamlValue(value)).join(', ')}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 업로드 URL인지 확인한다.
|
||||
* @param {string} value - URL
|
||||
* @returns {boolean} 내부 업로드 URL 여부
|
||||
*/
|
||||
const isLocalUploadUrl = (value) => String(value || '').startsWith('/uploads/')
|
||||
|
||||
/**
|
||||
* 이미지 확장자인지 확인한다.
|
||||
* @param {string} value - 파일명 또는 URL
|
||||
* @returns {boolean} 이미지 여부
|
||||
*/
|
||||
const isImageAsset = (value) => /\.(avif|gif|jpe?g|png|svg|webp)$/i.test(String(value || '').split(/[?#]/)[0])
|
||||
|
||||
/**
|
||||
* 업로드 URL을 안전한 디스크 경로로 변환한다.
|
||||
* @param {string} url - 내부 업로드 URL
|
||||
* @returns {string|null} 디스크 경로
|
||||
*/
|
||||
const resolveUploadUrlToDiskPath = (url) => {
|
||||
const pathOnly = String(url || '').split(/[?#]/)[0]
|
||||
|
||||
if (!isLocalUploadUrl(pathOnly)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const relativeUploadPath = decodeURIComponent(pathOnly.replace(/^\/uploads\/?/, ''))
|
||||
const diskPath = join(UPLOAD_ROOT, relativeUploadPath)
|
||||
const safeRelativePath = relative(UPLOAD_ROOT, diskPath)
|
||||
|
||||
if (!safeRelativePath || safeRelativePath.startsWith('..')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return diskPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문에서 내부 업로드 URL을 수집한다.
|
||||
* @param {string} content - 게시물 본문
|
||||
* @returns {Array<string>} 내부 업로드 URL 목록
|
||||
*/
|
||||
const collectLocalUploadUrls = (content) => {
|
||||
const matches = String(content || '').match(localUploadUrlPattern) || []
|
||||
return [...new Set(matches.filter(isLocalUploadUrl))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 frontmatter를 만든다.
|
||||
* @param {Object} post - 게시물
|
||||
* @param {Object} assets - 변환된 자산 경로
|
||||
* @returns {string} frontmatter 문자열
|
||||
*/
|
||||
const createPostFrontmatter = (post, assets) => {
|
||||
const lines = [
|
||||
'---',
|
||||
`id: ${quoteYamlValue(post.id)}`,
|
||||
`title: ${quoteYamlValue(post.title)}`,
|
||||
`slug: ${quoteYamlValue(post.slug)}`,
|
||||
`status: ${quoteYamlValue(post.status)}`,
|
||||
`published_at: ${post.published_at ? quoteYamlValue(post.published_at.toISOString()) : 'null'}`,
|
||||
`created_at: ${quoteYamlValue(post.created_at.toISOString())}`,
|
||||
`updated_at: ${quoteYamlValue(post.updated_at.toISOString())}`,
|
||||
`excerpt: ${quoteYamlValue(post.excerpt || '')}`,
|
||||
`featured_image: ${assets.featuredImage ? quoteYamlValue(assets.featuredImage) : 'null'}`,
|
||||
`seo_title: ${quoteYamlValue(post.seo_title || '')}`,
|
||||
`seo_description: ${quoteYamlValue(post.seo_description || '')}`,
|
||||
`canonical_url: ${quoteYamlValue(post.canonical_url || '')}`,
|
||||
`noindex: ${post.noindex ? 'true' : 'false'}`,
|
||||
`og_image: ${assets.ogImage ? quoteYamlValue(assets.ogImage) : 'null'}`,
|
||||
`tags: ${formatYamlArray(post.tags || [])}`,
|
||||
'---',
|
||||
''
|
||||
]
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물의 내부 업로드 자산을 ZIP 파일 목록으로 변환한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @param {string} postFolder - 게시물 폴더명
|
||||
* @returns {Promise<Object>} 변환 결과
|
||||
*/
|
||||
const buildPostAssetFiles = async (post, postFolder) => {
|
||||
const urls = new Set(collectLocalUploadUrls(post.content))
|
||||
|
||||
if (isLocalUploadUrl(post.featured_image)) {
|
||||
urls.add(post.featured_image)
|
||||
}
|
||||
|
||||
if (isLocalUploadUrl(post.og_image)) {
|
||||
urls.add(post.og_image)
|
||||
}
|
||||
|
||||
const replacements = {}
|
||||
const zipFiles = []
|
||||
const usedAssetNames = new Set()
|
||||
|
||||
for (const url of urls) {
|
||||
const diskPath = resolveUploadUrlToDiskPath(url)
|
||||
|
||||
if (!diskPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(diskPath)
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const assetFolder = isImageAsset(url) ? 'images' : 'files'
|
||||
const fileName = createUniqueAssetName(basename(diskPath), usedAssetNames)
|
||||
const localPath = `./${assetFolder}/${fileName}`
|
||||
|
||||
replacements[url] = localPath
|
||||
zipFiles.push({
|
||||
path: `${postFolder}/${assetFolder}/${fileName}`,
|
||||
data: await readFile(diskPath)
|
||||
})
|
||||
} catch {
|
||||
replacements[url] = url
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
replacements,
|
||||
zipFiles
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물을 Obsidian 친화적인 Markdown 파일 묶음으로 변환한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @param {Set<string>} usedFolderNames - 사용한 폴더명
|
||||
* @returns {Promise<Array<{path: string, data: Buffer|string}>>} ZIP 내부 파일 목록
|
||||
*/
|
||||
const buildPostZipFiles = async (post, usedFolderNames) => {
|
||||
const postFolder = createUniqueSegment(post.title || post.slug || post.id, usedFolderNames)
|
||||
const { replacements, zipFiles } = await buildPostAssetFiles(post, postFolder)
|
||||
const replaceUrl = (value) => {
|
||||
let next = String(value || '')
|
||||
Object.entries(replacements).forEach(([source, target]) => {
|
||||
next = next.split(source).join(target)
|
||||
})
|
||||
return next
|
||||
}
|
||||
const markdown = [
|
||||
createPostFrontmatter(post, {
|
||||
featuredImage: replacements[post.featured_image] || post.featured_image || '',
|
||||
ogImage: replacements[post.og_image] || post.og_image || ''
|
||||
}),
|
||||
replaceUrl(post.content || '')
|
||||
].join('\n')
|
||||
|
||||
return [
|
||||
{
|
||||
path: `${postFolder}/${sanitizeFilenameSegment(post.title || post.slug || 'post')}.md`,
|
||||
data: markdown
|
||||
},
|
||||
...zipFiles
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* export 작업 행을 응답 구조로 변환한다.
|
||||
* @param {Object} row - DB 행
|
||||
@@ -161,6 +401,28 @@ export const listPostExportJobs = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 가능한 대기 작업 ID 목록 조회
|
||||
* @returns {Promise<Array<string>>} 대기 작업 ID 목록
|
||||
*/
|
||||
export const listQueuedPostExportJobIds = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM post_export_jobs
|
||||
WHERE status = ${EXPORT_STATUS.QUEUED}
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 3
|
||||
`
|
||||
|
||||
return rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 export 작업 요청 생성
|
||||
* @param {Object} input - 작업 요청 입력
|
||||
@@ -275,3 +537,362 @@ export const createPostExportJob = async (input) => {
|
||||
files: []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 작업 상세를 조회한다.
|
||||
* @param {string} jobId - 작업 ID
|
||||
* @returns {Promise<Object|null>} Export 작업
|
||||
*/
|
||||
export const getPostExportJobById = async (jobId) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return null
|
||||
}
|
||||
|
||||
const jobRows = await sql`
|
||||
SELECT *
|
||||
FROM post_export_jobs
|
||||
WHERE id = ${jobId}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
if (!jobRows[0]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fileRows = await sql`
|
||||
SELECT *
|
||||
FROM post_export_files
|
||||
WHERE job_id = ${jobId}
|
||||
ORDER BY part_index ASC
|
||||
`
|
||||
|
||||
return mapPostExportJobRow({
|
||||
...jobRows[0],
|
||||
files: fileRows.map(mapPostExportFileRow)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 작업을 처리 중 상태로 변경한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {string} jobId - 작업 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const markPostExportJobProcessing = async (sql, jobId) => {
|
||||
await sql`
|
||||
UPDATE post_export_jobs
|
||||
SET status = ${EXPORT_STATUS.PROCESSING},
|
||||
started_at = COALESCE(started_at, now()),
|
||||
progress_message = 'Export 파일 생성을 시작했습니다.',
|
||||
updated_at = now()
|
||||
WHERE id = ${jobId}
|
||||
AND status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING})
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 작업 진행도를 저장한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {Object} input - 진행도 입력
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const updatePostExportJobProgress = async (sql, input) => {
|
||||
await sql`
|
||||
UPDATE post_export_jobs
|
||||
SET processed_count = ${input.processedCount},
|
||||
current_part_index = ${input.currentPartIndex},
|
||||
progress_message = ${input.progressMessage},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.jobId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 작업을 완료 상태로 변경한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {string} jobId - 작업 ID
|
||||
* @param {number} postCount - 전체 게시물 수
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const markPostExportJobReady = async (sql, jobId, postCount) => {
|
||||
await sql`
|
||||
UPDATE post_export_jobs
|
||||
SET status = ${EXPORT_STATUS.READY},
|
||||
processed_count = ${postCount},
|
||||
current_part_index = null,
|
||||
message = '게시물 Export 파일 생성이 완료되었습니다.',
|
||||
progress_message = '생성이 완료되었습니다.',
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = ${jobId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 작업을 실패 상태로 변경한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {string} jobId - 작업 ID
|
||||
* @param {unknown} error - 오류
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const markPostExportJobFailed = async (sql, jobId, error) => {
|
||||
const message = error instanceof Error ? error.message : '알 수 없는 오류'
|
||||
|
||||
await sql`
|
||||
UPDATE post_export_jobs
|
||||
SET status = ${EXPORT_STATUS.FAILED},
|
||||
message = ${`게시물 Export 생성 실패: ${message}`},
|
||||
progress_message = ${message},
|
||||
current_part_index = null,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = ${jobId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 분할 파일을 처리 중 상태로 변경한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {string} fileId - 파일 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const markPostExportFileProcessing = async (sql, fileId) => {
|
||||
await sql`
|
||||
UPDATE post_export_files
|
||||
SET status = ${EXPORT_FILE_STATUS.PROCESSING},
|
||||
updated_at = now()
|
||||
WHERE id = ${fileId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 분할 파일을 완료 상태로 변경한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {Object} input - 파일 완료 입력
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const markPostExportFileReady = async (sql, input) => {
|
||||
await sql`
|
||||
UPDATE post_export_files
|
||||
SET status = ${EXPORT_FILE_STATUS.READY},
|
||||
file_path = ${input.filePath},
|
||||
file_size_bytes = ${input.fileSizeBytes},
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = ${input.fileId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 분할 파일을 실패 상태로 변경한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {string} fileId - 파일 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const markPostExportFileFailed = async (sql, fileId) => {
|
||||
await sql`
|
||||
UPDATE post_export_files
|
||||
SET status = ${EXPORT_FILE_STATUS.FAILED},
|
||||
updated_at = now()
|
||||
WHERE id = ${fileId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 분할 범위에 해당하는 게시물을 조회한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @param {Object} job - Export 작업
|
||||
* @param {Object} file - Export 분할 파일
|
||||
* @returns {Promise<Array>} 게시물 목록
|
||||
*/
|
||||
const listPostsForExportFile = async (sql, job, file) => {
|
||||
const limit = Number(file.postEnd || 0) - Number(file.postStart || 0) + 1
|
||||
const offset = Number(file.postStart || 1) - 1
|
||||
|
||||
if (job.scope === 'author') {
|
||||
return sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
COALESCE(array_agg(tags.slug ORDER BY tags.sort_order ASC, tags.name ASC) FILTER (WHERE tags.id IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||
WHERE posts.author_id = ${job.requestedBy}
|
||||
GROUP BY posts.id
|
||||
ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`
|
||||
}
|
||||
|
||||
return sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
COALESCE(array_agg(tags.slug ORDER BY tags.sort_order ASC, tags.name ASC) FILTER (WHERE tags.id IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||
GROUP BY posts.id
|
||||
ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Export ZIP 파일을 생성한다.
|
||||
* @param {Object} job - Export 작업
|
||||
* @param {Object} file - Export 분할 파일
|
||||
* @returns {Promise<Object>} 생성 결과
|
||||
*/
|
||||
const createPostExportZipFile = async (job, file) => {
|
||||
const sql = getPostgresClient()
|
||||
const posts = await listPostsForExportFile(sql, job, file)
|
||||
const usedFolderNames = new Set()
|
||||
const zipEntries = []
|
||||
|
||||
for (const post of posts) {
|
||||
zipEntries.push(...await buildPostZipFiles(post, usedFolderNames))
|
||||
}
|
||||
|
||||
const zipBuffer = createZipBuffer(zipEntries)
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const relativePath = join(EXPORT_ROOT_NAME, year, month, job.id, file.fileName)
|
||||
const absolutePath = join(UPLOAD_ROOT, relativePath)
|
||||
|
||||
await mkdir(dirname(absolutePath), { recursive: true })
|
||||
await writeFile(absolutePath, zipBuffer)
|
||||
|
||||
return {
|
||||
relativePath,
|
||||
fileSizeBytes: zipBuffer.length,
|
||||
postCount: posts.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 Export 작업을 실제로 실행한다.
|
||||
* @param {string} jobId - Export 작업 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const runPostExportJob = async (jobId) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql || runningPostExportJobIds.has(jobId)) {
|
||||
return
|
||||
}
|
||||
|
||||
runningPostExportJobIds.add(jobId)
|
||||
|
||||
try {
|
||||
const job = await getPostExportJobById(jobId)
|
||||
|
||||
if (!job || ![EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) {
|
||||
return
|
||||
}
|
||||
|
||||
await markPostExportJobProcessing(sql, jobId)
|
||||
|
||||
const readyPostCount = job.files
|
||||
.filter((file) => file.status === EXPORT_FILE_STATUS.READY)
|
||||
.reduce((total, file) => total + Math.max(file.postEnd - file.postStart + 1, 0), 0)
|
||||
let processedCount = Math.max(Number(job.processedCount || 0), readyPostCount)
|
||||
|
||||
for (const file of job.files) {
|
||||
if (file.status === EXPORT_FILE_STATUS.READY) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await markPostExportFileProcessing(sql, file.id)
|
||||
await updatePostExportJobProgress(sql, {
|
||||
jobId,
|
||||
processedCount,
|
||||
currentPartIndex: file.partIndex,
|
||||
progressMessage: `${file.partIndex}번째 분할 파일을 생성하는 중입니다.`
|
||||
})
|
||||
|
||||
const result = await createPostExportZipFile(job, file)
|
||||
processedCount += result.postCount
|
||||
await markPostExportFileReady(sql, {
|
||||
fileId: file.id,
|
||||
filePath: result.relativePath,
|
||||
fileSizeBytes: result.fileSizeBytes
|
||||
})
|
||||
await updatePostExportJobProgress(sql, {
|
||||
jobId,
|
||||
processedCount,
|
||||
currentPartIndex: file.partIndex,
|
||||
progressMessage: `${file.fileName} 생성 완료 (${processedCount.toLocaleString()} / ${job.postCount.toLocaleString()})`
|
||||
})
|
||||
} catch (error) {
|
||||
await markPostExportFileFailed(sql, file.id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
await markPostExportJobReady(sql, jobId, job.postCount)
|
||||
} catch (error) {
|
||||
await markPostExportJobFailed(sql, jobId, error)
|
||||
} finally {
|
||||
runningPostExportJobIds.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export 작업을 백그라운드 실행 대기열에 올린다.
|
||||
* @param {string} jobId - Export 작업 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
export const queuePostExportJobRun = (jobId) => {
|
||||
if (!jobId || runningPostExportJobIds.has(jobId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
runPostExportJob(jobId).catch((error) => {
|
||||
console.error('게시물 Export 작업 실행 실패', error)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 대기 중인 Export 작업을 백그라운드 실행 대기열에 올린다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const queuePendingPostExportJobs = async () => {
|
||||
const jobIds = await listQueuedPostExportJobIds()
|
||||
jobIds.forEach(queuePostExportJobRun)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다운로드 가능한 Export 파일을 조회한다.
|
||||
* @param {string} fileId - Export 파일 ID
|
||||
* @returns {Promise<Object|null>} Export 파일
|
||||
*/
|
||||
export const getReadyPostExportFile = async (fileId) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM post_export_files
|
||||
WHERE id = ${fileId}
|
||||
AND status = ${EXPORT_FILE_STATUS.READY}
|
||||
AND file_path <> ''
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
if (!rows[0]) {
|
||||
return null
|
||||
}
|
||||
|
||||
return mapPostExportFileRow(rows[0])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { listPostExportJobs } from '../../../../repositories/post-export-repository'
|
||||
import {
|
||||
listPostExportJobs,
|
||||
queuePendingPostExportJobs
|
||||
} from '../../../../repositories/post-export-repository'
|
||||
|
||||
/**
|
||||
* 관리자 게시물 Export 작업 목록 API
|
||||
@@ -8,6 +11,9 @@ import { listPostExportJobs } from '../../../../repositories/post-export-reposit
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
requireAdminSession(event)
|
||||
queuePendingPostExportJobs().catch((error) => {
|
||||
console.error('대기 중인 게시물 Export 작업 실행 실패', error)
|
||||
})
|
||||
|
||||
return listPostExportJobs()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { createPostExportJob } from '../../../../repositories/post-export-repository'
|
||||
import {
|
||||
createPostExportJob,
|
||||
queuePostExportJobRun
|
||||
} from '../../../../repositories/post-export-repository'
|
||||
|
||||
const postExportJobInputSchema = z.object({
|
||||
chunkSize: z.number().int().min(1).max(500).optional(),
|
||||
@@ -17,11 +20,17 @@ export default defineEventHandler(async (event) => {
|
||||
const adminSession = requireAdminSession(event)
|
||||
const input = postExportJobInputSchema.parse(await readBody(event))
|
||||
|
||||
return createPostExportJob({
|
||||
const job = await createPostExportJob({
|
||||
requestedBy: adminSession.userId,
|
||||
requestedEmail: adminSession.email,
|
||||
scope: 'all',
|
||||
chunkSize: input.chunkSize,
|
||||
retentionDays: input.retentionDays
|
||||
})
|
||||
|
||||
if (job.status === 'queued') {
|
||||
queuePostExportJobRun(job.id)
|
||||
}
|
||||
|
||||
return job
|
||||
})
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
import {
|
||||
createError,
|
||||
getRouterParam,
|
||||
sendStream,
|
||||
setResponseHeader
|
||||
} from 'h3'
|
||||
import { requireAdminSession } from '../../../../../../utils/admin-auth'
|
||||
import { getReadyPostExportFile } from '../../../../../../repositories/post-export-repository'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
|
||||
/**
|
||||
* Export 파일 경로를 안전하게 해석한다.
|
||||
* @param {string} filePath - DB에 저장된 상대 경로
|
||||
* @returns {string|null} 디스크 경로
|
||||
*/
|
||||
const resolveExportFilePath = (filePath) => {
|
||||
const absolutePath = join(uploadRoot, filePath || '')
|
||||
const relativePath = relative(uploadRoot, absolutePath)
|
||||
|
||||
if (!relativePath || relativePath.startsWith('..')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 게시물 Export 파일 다운로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<unknown>} ZIP 파일 스트림
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const fileId = getRouterParam(event, 'fileId')
|
||||
|
||||
if (!fileId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Export 파일 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const exportFile = await getReadyPostExportFile(fileId)
|
||||
|
||||
if (!exportFile) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Export 파일을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const absolutePath = resolveExportFilePath(exportFile.filePath)
|
||||
|
||||
if (!absolutePath) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Export 파일 경로가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const fileStat = await stat(absolutePath).catch(() => null)
|
||||
|
||||
if (!fileStat?.isFile()) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Export 파일이 서버에 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'content-type', 'application/zip')
|
||||
setResponseHeader(event, 'content-length', String(fileStat.size))
|
||||
setResponseHeader(event, 'cache-control', 'private, no-store')
|
||||
setResponseHeader(
|
||||
event,
|
||||
'content-disposition',
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(exportFile.fileName)}`
|
||||
)
|
||||
|
||||
return sendStream(event, createReadStream(absolutePath))
|
||||
})
|
||||
167
server/utils/zip-writer.js
Normal file
167
server/utils/zip-writer.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { deflateRawSync } from 'node:zlib'
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, index) => {
|
||||
let value = index
|
||||
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
||||
}
|
||||
|
||||
return value >>> 0
|
||||
})
|
||||
|
||||
/**
|
||||
* CRC32 값을 계산한다.
|
||||
* @param {Buffer} input - 원본 데이터
|
||||
* @returns {number} CRC32
|
||||
*/
|
||||
const calculateCrc32 = (input) => {
|
||||
let crc = 0xffffffff
|
||||
|
||||
for (const byte of input) {
|
||||
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP DOS 날짜/시간 값을 만든다.
|
||||
* @param {Date} date - 기준 날짜
|
||||
* @returns {{ dosDate: number, dosTime: number }} DOS 날짜/시간
|
||||
*/
|
||||
const createDosDateTime = (date) => {
|
||||
const year = Math.max(date.getFullYear(), 1980)
|
||||
|
||||
return {
|
||||
dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
||||
dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 엔트리 경로를 정리한다.
|
||||
* @param {string} pathValue - 엔트리 경로
|
||||
* @returns {string} 정리된 경로
|
||||
*/
|
||||
const normalizeZipPath = (pathValue) => String(pathValue || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/g, '')
|
||||
.replace(/\.\.+/g, '.')
|
||||
|
||||
/**
|
||||
* ZIP 로컬 파일 헤더를 만든다.
|
||||
* @param {Object} entry - 엔트리 정보
|
||||
* @returns {Buffer} 로컬 파일 헤더
|
||||
*/
|
||||
const createLocalFileHeader = (entry) => {
|
||||
const header = Buffer.alloc(30)
|
||||
header.writeUInt32LE(0x04034b50, 0)
|
||||
header.writeUInt16LE(20, 4)
|
||||
header.writeUInt16LE(0x0800, 6)
|
||||
header.writeUInt16LE(8, 8)
|
||||
header.writeUInt16LE(entry.dosTime, 10)
|
||||
header.writeUInt16LE(entry.dosDate, 12)
|
||||
header.writeUInt32LE(entry.crc32, 14)
|
||||
header.writeUInt32LE(entry.compressedSize, 18)
|
||||
header.writeUInt32LE(entry.uncompressedSize, 22)
|
||||
header.writeUInt16LE(entry.name.length, 26)
|
||||
header.writeUInt16LE(0, 28)
|
||||
|
||||
return Buffer.concat([header, entry.name])
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 중앙 디렉터리 헤더를 만든다.
|
||||
* @param {Object} entry - 엔트리 정보
|
||||
* @returns {Buffer} 중앙 디렉터리 헤더
|
||||
*/
|
||||
const createCentralDirectoryHeader = (entry) => {
|
||||
const header = Buffer.alloc(46)
|
||||
header.writeUInt32LE(0x02014b50, 0)
|
||||
header.writeUInt16LE(20, 4)
|
||||
header.writeUInt16LE(20, 6)
|
||||
header.writeUInt16LE(0x0800, 8)
|
||||
header.writeUInt16LE(8, 10)
|
||||
header.writeUInt16LE(entry.dosTime, 12)
|
||||
header.writeUInt16LE(entry.dosDate, 14)
|
||||
header.writeUInt32LE(entry.crc32, 16)
|
||||
header.writeUInt32LE(entry.compressedSize, 20)
|
||||
header.writeUInt32LE(entry.uncompressedSize, 24)
|
||||
header.writeUInt16LE(entry.name.length, 28)
|
||||
header.writeUInt16LE(0, 30)
|
||||
header.writeUInt16LE(0, 32)
|
||||
header.writeUInt16LE(0, 34)
|
||||
header.writeUInt16LE(0, 36)
|
||||
header.writeUInt32LE(0, 38)
|
||||
header.writeUInt32LE(entry.offset, 42)
|
||||
|
||||
return Buffer.concat([header, entry.name])
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 종료 레코드를 만든다.
|
||||
* @param {number} entryCount - 엔트리 개수
|
||||
* @param {number} centralDirectorySize - 중앙 디렉터리 크기
|
||||
* @param {number} centralDirectoryOffset - 중앙 디렉터리 시작 위치
|
||||
* @returns {Buffer} 종료 레코드
|
||||
*/
|
||||
const createEndOfCentralDirectory = (entryCount, centralDirectorySize, centralDirectoryOffset) => {
|
||||
const endRecord = Buffer.alloc(22)
|
||||
endRecord.writeUInt32LE(0x06054b50, 0)
|
||||
endRecord.writeUInt16LE(0, 4)
|
||||
endRecord.writeUInt16LE(0, 6)
|
||||
endRecord.writeUInt16LE(entryCount, 8)
|
||||
endRecord.writeUInt16LE(entryCount, 10)
|
||||
endRecord.writeUInt32LE(centralDirectorySize, 12)
|
||||
endRecord.writeUInt32LE(centralDirectoryOffset, 16)
|
||||
endRecord.writeUInt16LE(0, 20)
|
||||
|
||||
return endRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리에서 ZIP 파일 버퍼를 만든다.
|
||||
* @param {Array<{ path: string, data: Buffer | string }>} files - ZIP 파일 목록
|
||||
* @returns {Buffer} ZIP 버퍼
|
||||
*/
|
||||
export const createZipBuffer = (files) => {
|
||||
const localFileParts = []
|
||||
const centralDirectoryParts = []
|
||||
const entries = []
|
||||
let offset = 0
|
||||
|
||||
for (const file of files) {
|
||||
const normalizedPath = normalizeZipPath(file.path)
|
||||
if (!normalizedPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rawData = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data), 'utf8')
|
||||
const compressedData = deflateRawSync(rawData)
|
||||
const { dosDate, dosTime } = createDosDateTime(new Date())
|
||||
const entry = {
|
||||
name: Buffer.from(normalizedPath, 'utf8'),
|
||||
crc32: calculateCrc32(rawData),
|
||||
compressedSize: compressedData.length,
|
||||
uncompressedSize: rawData.length,
|
||||
dosDate,
|
||||
dosTime,
|
||||
offset
|
||||
}
|
||||
const localHeader = createLocalFileHeader(entry)
|
||||
|
||||
localFileParts.push(localHeader, compressedData)
|
||||
offset += localHeader.length + compressedData.length
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
centralDirectoryParts.push(createCentralDirectoryHeader(entry))
|
||||
}
|
||||
|
||||
const centralDirectory = Buffer.concat(centralDirectoryParts)
|
||||
const endRecord = createEndOfCentralDirectory(entries.length, centralDirectory.length, offset)
|
||||
|
||||
return Buffer.concat([...localFileParts, centralDirectory, endRecord])
|
||||
}
|
||||
Reference in New Issue
Block a user