v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join, relative } from 'node:path'
|
||||
import { createZipBuffer } from '../utils/zip-writer'
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join, relative, sep } from 'node:path'
|
||||
import { writeZipFile } from '../utils/zip-writer'
|
||||
import { getRuntimeEnvValue } from '../utils/runtime-env'
|
||||
import { isResendConfigured, sendResendEmail } from '../utils/resend-mail'
|
||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||
import { getUploadRoot } from '../utils/upload-root'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
import {
|
||||
deleteMediaCatalogEntries,
|
||||
recordMediaCatalogFile
|
||||
} from './media-catalog-repository'
|
||||
|
||||
const EXPORT_STATUS = {
|
||||
QUEUED: 'queued',
|
||||
@@ -26,7 +31,7 @@ const DEFAULT_RETENTION_DAYS = 100
|
||||
const DEFAULT_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024
|
||||
const MIN_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads')
|
||||
const UPLOAD_ROOT = getUploadRoot()
|
||||
const EXPORT_ROOT_NAME = 'exports'
|
||||
const runningPostExportJobIds = new Set()
|
||||
const localUploadUrlPattern = /\/uploads\/[^\s"'<>)]*/g
|
||||
@@ -245,7 +250,8 @@ const buildPostAssetFiles = async (post, postFolder) => {
|
||||
replacements[url] = localPath
|
||||
zipFiles.push({
|
||||
path: `${postFolder}/${assetFolder}/${fileName}`,
|
||||
data: await readFile(diskPath)
|
||||
sourcePath: diskPath,
|
||||
store: true
|
||||
})
|
||||
} catch {
|
||||
replacements[url] = url
|
||||
@@ -262,7 +268,7 @@ const buildPostAssetFiles = async (post, postFolder) => {
|
||||
* 게시물을 Obsidian 친화적인 Markdown 파일 묶음으로 변환한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @param {Set<string>} usedFolderNames - 사용한 폴더명
|
||||
* @returns {Promise<Array<{path: string, data: Buffer|string}>>} ZIP 내부 파일 목록
|
||||
* @returns {Promise<Array<{path: string, data?: Buffer|string, sourcePath?: string, store?: boolean}>>} ZIP 내부 파일 목록
|
||||
*/
|
||||
const buildPostZipFiles = async (post, usedFolderNames) => {
|
||||
const postFolder = createUniqueSegment(post.title || post.slug || post.id, usedFolderNames)
|
||||
@@ -724,6 +730,7 @@ export const cleanupExpiredPostExportJobs = async () => {
|
||||
FROM post_export_files
|
||||
WHERE job_id = ANY(${jobIds})
|
||||
`
|
||||
const deletedMediaUrls = []
|
||||
|
||||
for (const file of fileRows) {
|
||||
if (!file.file_path) {
|
||||
@@ -734,9 +741,11 @@ export const cleanupExpiredPostExportJobs = async () => {
|
||||
|
||||
if (absolutePath) {
|
||||
await rm(absolutePath, { force: true })
|
||||
deletedMediaUrls.push(`/uploads/${String(file.file_path).split(sep).join('/').replace(/^\/+/, '')}`)
|
||||
}
|
||||
}
|
||||
|
||||
await deleteMediaCatalogEntries(deletedMediaUrls)
|
||||
await sql`
|
||||
DELETE FROM post_export_jobs
|
||||
WHERE id = ANY(${jobIds})
|
||||
@@ -790,8 +799,8 @@ export const listPostExportJobs = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 가능한 대기 작업 ID 목록 조회
|
||||
* @returns {Promise<Array<string>>} 대기 작업 ID 목록
|
||||
* 실행하거나 재개할 Export 작업 ID 목록 조회
|
||||
* @returns {Promise<Array<string>>} 실행 가능한 작업 ID 목록
|
||||
*/
|
||||
export const listQueuedPostExportJobIds = async () => {
|
||||
const sql = getPostgresClient()
|
||||
@@ -803,7 +812,7 @@ export const listQueuedPostExportJobIds = async () => {
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM post_export_jobs
|
||||
WHERE status = ${EXPORT_STATUS.QUEUED}
|
||||
WHERE status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING})
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 3
|
||||
`
|
||||
@@ -1167,7 +1176,6 @@ const createPostExportZipFile = async (job, file) => {
|
||||
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')
|
||||
@@ -1175,11 +1183,18 @@ const createPostExportZipFile = async (job, file) => {
|
||||
const absolutePath = join(UPLOAD_ROOT, relativePath)
|
||||
|
||||
await mkdir(dirname(absolutePath), { recursive: true })
|
||||
await writeFile(absolutePath, zipBuffer)
|
||||
await rm(absolutePath, { force: true })
|
||||
await writeZipFile(absolutePath, zipEntries)
|
||||
const fileStat = await stat(absolutePath)
|
||||
await recordMediaCatalogFile(`/uploads/${relativePath.split(sep).join('/')}`, {
|
||||
origin: 'system',
|
||||
role: 'export',
|
||||
category: 'exports'
|
||||
})
|
||||
|
||||
return {
|
||||
relativePath,
|
||||
fileSizeBytes: zipBuffer.length,
|
||||
fileSizeBytes: fileStat.size,
|
||||
postCount: posts.length
|
||||
}
|
||||
}
|
||||
@@ -1197,8 +1212,24 @@ export const runPostExportJob = async (jobId) => {
|
||||
}
|
||||
|
||||
runningPostExportJobIds.add(jobId)
|
||||
let reservedSql = null
|
||||
let advisoryLockAcquired = false
|
||||
const advisoryLockNamespace = 'sori-post-export'
|
||||
|
||||
try {
|
||||
reservedSql = await sql.reserve()
|
||||
const lockRows = await reservedSql`
|
||||
SELECT pg_try_advisory_lock(
|
||||
hashtext(${advisoryLockNamespace}),
|
||||
hashtext(${jobId})
|
||||
) AS acquired
|
||||
`
|
||||
advisoryLockAcquired = Boolean(lockRows[0]?.acquired)
|
||||
|
||||
if (!advisoryLockAcquired) {
|
||||
return
|
||||
}
|
||||
|
||||
const job = await getPostExportJobById(jobId)
|
||||
|
||||
if (!job || ![EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) {
|
||||
@@ -1263,9 +1294,31 @@ export const runPostExportJob = async (jobId) => {
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
await markPostExportJobFailed(sql, jobId, error)
|
||||
if (advisoryLockAcquired) {
|
||||
await markPostExportJobFailed(sql, jobId, error)
|
||||
} else {
|
||||
console.error('게시물 Export 작업 잠금 획득 실패', error)
|
||||
}
|
||||
} finally {
|
||||
if (reservedSql && advisoryLockAcquired) {
|
||||
await reservedSql`
|
||||
SELECT pg_advisory_unlock(
|
||||
hashtext(${advisoryLockNamespace}),
|
||||
hashtext(${jobId})
|
||||
)
|
||||
`.catch((error) => {
|
||||
console.error('게시물 Export 작업 잠금 해제 실패', error)
|
||||
})
|
||||
}
|
||||
reservedSql?.release()
|
||||
runningPostExportJobIds.delete(jobId)
|
||||
if (advisoryLockAcquired) {
|
||||
setTimeout(() => {
|
||||
queuePendingPostExportJobs().catch((error) => {
|
||||
console.error('후속 게시물 Export 작업 실행 실패', error)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1287,7 +1340,7 @@ export const queuePostExportJobRun = (jobId) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 대기 중인 Export 작업을 백그라운드 실행 대기열에 올린다.
|
||||
* 대기 중이거나 중단된 Export 작업을 백그라운드 실행 대기열에 올린다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const queuePendingPostExportJobs = async () => {
|
||||
@@ -1389,6 +1442,8 @@ export const deletePostExportJob = async (jobId) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const deletedMediaUrls = []
|
||||
|
||||
for (const file of job.files) {
|
||||
if (!file.filePath) {
|
||||
continue
|
||||
@@ -1398,9 +1453,11 @@ export const deletePostExportJob = async (jobId) => {
|
||||
|
||||
if (absolutePath) {
|
||||
await rm(absolutePath, { force: true })
|
||||
deletedMediaUrls.push(`/uploads/${String(file.filePath).split(sep).join('/').replace(/^\/+/, '')}`)
|
||||
}
|
||||
}
|
||||
|
||||
await deleteMediaCatalogEntries(deletedMediaUrls)
|
||||
await sql`
|
||||
DELETE FROM post_export_jobs
|
||||
WHERE id = ${jobId}
|
||||
|
||||
Reference in New Issue
Block a user