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,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}