v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,7 +16,7 @@ const deleteAccountSchema = z.object({
|
||||
* @returns {Promise<{ ok: true }>} 처리 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const parsedBody = deleteAccountSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
* @returns {Promise<{ ok: true }>} 처리 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
|
||||
import { replaceMediaReferences } from '../../repositories/media-catalog-repository'
|
||||
|
||||
/**
|
||||
* 회원 썸네일 업로드 API
|
||||
@@ -11,7 +12,7 @@ import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/m
|
||||
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const currentUser = await getUserById(session.userId)
|
||||
if (!currentUser) {
|
||||
throw createError({
|
||||
@@ -29,6 +30,13 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
|
||||
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
|
||||
await replaceMediaReferences('user', session.userId, {
|
||||
avatarUrl
|
||||
}, {
|
||||
title: currentUser.username
|
||||
}).catch(() => {
|
||||
console.warn('[media-catalog] 회원 아바타 참조 갱신 실패')
|
||||
})
|
||||
|
||||
if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) {
|
||||
await removeManagedAvatarAsset(currentUser.avatarUrl)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { requireMemberSession } from '../../utils/member-auth'
|
||||
* @returns {Promise<{ available: boolean }>} 사용 가능 여부
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const rawUsername = getQuery(event).username
|
||||
const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import { createError, readBody } from 'h3'
|
||||
import { getUserByEmail, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { setMemberSession } from '../../utils/member-auth'
|
||||
import { getClientIp } from '../../utils/request-ip'
|
||||
import {
|
||||
assertLoginAttemptAllowed,
|
||||
clearLoginAccountFailures,
|
||||
recordLoginFailure
|
||||
} from '../../utils/login-attempt-limit'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
@@ -27,19 +32,34 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const body = parsedBody.data
|
||||
const emailNorm = body.email.trim().toLowerCase()
|
||||
const ip = getClientIp(event)
|
||||
const attempt = {
|
||||
scope: 'member',
|
||||
email: emailNorm,
|
||||
ip
|
||||
}
|
||||
|
||||
assertLoginAttemptAllowed(event, attempt)
|
||||
|
||||
const user = await getUserByEmail(emailNorm)
|
||||
|
||||
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
|
||||
recordLoginFailure(attempt)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
setMemberSession(event, { userId: user.id, email: user.email })
|
||||
clearLoginAccountFailures(attempt)
|
||||
setMemberSession(event, {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
sessionVersion: user.sessionVersion
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: user.id,
|
||||
ip: getClientIp(event)
|
||||
ip
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { getMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
import { getVerifiedMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 회원 세션 조회 API
|
||||
@@ -8,7 +8,9 @@ import { getMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const isOptional = getQuery(event).optional === '1'
|
||||
const session = isOptional ? getMemberSession(event) : requireMemberSession(event)
|
||||
const session = isOptional
|
||||
? await getVerifiedMemberSession(event)
|
||||
: await requireMemberSession(event)
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
|
||||
@@ -2,7 +2,8 @@ import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { getUserByIdWithPassword, updateMemberPassword } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
import { clearAdminSession } from '../../utils/admin-auth'
|
||||
|
||||
const updatePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
@@ -15,7 +16,7 @@ const updatePasswordSchema = z.object({
|
||||
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const parsedBody = updatePasswordSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
@@ -46,6 +47,8 @@ export default defineEventHandler(async (event) => {
|
||||
userId: session.userId,
|
||||
passwordHash: nextHash
|
||||
})
|
||||
clearMemberSession(event)
|
||||
clearAdminSession(event)
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createError } from 'h3'
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const user = await getUserProfileById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -16,7 +16,7 @@ const updateProfileSchema = z.object({
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 수정된 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const parsedBody = updateProfileSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
|
||||
@@ -104,11 +104,16 @@ export default defineEventHandler(async (event) => {
|
||||
passwordHash
|
||||
})
|
||||
|
||||
setMemberSession(event, { userId: created.id, email: created.email })
|
||||
setMemberSession(event, {
|
||||
userId: created.id,
|
||||
email: created.email,
|
||||
sessionVersion: created.sessionVersion
|
||||
})
|
||||
if (created.isAdmin) {
|
||||
setAdminSession(event, {
|
||||
userId: created.id,
|
||||
email: created.email
|
||||
email: created.email,
|
||||
sessionVersion: created.sessionVersion
|
||||
})
|
||||
}
|
||||
await touchUserActivity({
|
||||
|
||||
118
server/api/csp-report.post.js
Normal file
118
server/api/csp-report.post.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
createError,
|
||||
getRequestHeader,
|
||||
setResponseStatus
|
||||
} from 'h3'
|
||||
import {
|
||||
CSP_REPORT_MAX_BYTES,
|
||||
isSupportedCspReportContentType,
|
||||
parseCspReportPayload
|
||||
} from '../../lib/csp-report.js'
|
||||
import { recordCspReports } from '../repositories/csp-report-repository.js'
|
||||
import { assertCspReportRequestAllowed } from '../utils/csp-report-request-limit.js'
|
||||
|
||||
const STORAGE_FAILURE_LOG_INTERVAL_MS = 5 * 60 * 1000
|
||||
let lastStorageFailureLogAt = 0
|
||||
|
||||
/**
|
||||
* 요청 스트림을 최대 크기 안에서 JSON으로 읽는다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<unknown>} JSON payload
|
||||
*/
|
||||
const readLimitedJsonBody = async (event) => {
|
||||
const contentLengthHeader = String(getRequestHeader(event, 'content-length') || '').trim()
|
||||
if (contentLengthHeader) {
|
||||
const contentLength = Number(contentLengthHeader)
|
||||
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '올바르지 않은 Content-Length입니다.'
|
||||
})
|
||||
}
|
||||
if (contentLength > CSP_REPORT_MAX_BYTES) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'CSP 보고 본문이 너무 큽니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
let byteLength = 0
|
||||
|
||||
for await (const chunk of event.node.req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
byteLength += buffer.byteLength
|
||||
if (byteLength > CSP_REPORT_MAX_BYTES) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'CSP 보고 본문이 너무 큽니다.'
|
||||
})
|
||||
}
|
||||
chunks.push(buffer)
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '올바른 JSON CSP 보고서가 아닙니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 실패를 원문 정보 없이 제한된 빈도로 기록한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const logStorageFailure = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastStorageFailureLogAt < STORAGE_FAILURE_LOG_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
lastStorageFailureLogAt = now
|
||||
console.warn('CSP 위반 집계 저장에 실패했습니다.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 없는 CSP 위반 보고를 개인정보 최소화 집계로 저장한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<null>} 빈 204 응답
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertCspReportRequestAllowed(event)
|
||||
|
||||
const contentType = String(getRequestHeader(event, 'content-type') || '')
|
||||
if (!isSupportedCspReportContentType(contentType)) {
|
||||
throw createError({
|
||||
statusCode: 415,
|
||||
message: '지원하지 않는 CSP 보고 형식입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const payload = await readLimitedJsonBody(event)
|
||||
let reports
|
||||
|
||||
try {
|
||||
reports = parseCspReportPayload(
|
||||
payload,
|
||||
String(getRequestHeader(event, 'user-agent') || '')
|
||||
)
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '올바른 CSP 보고서가 아닙니다.'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await recordCspReports(reports)
|
||||
} catch {
|
||||
logStorageFailure()
|
||||
}
|
||||
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getQuery } from 'h3'
|
||||
import { listPosts } from '../repositories/content-repository'
|
||||
import { canAccessMembershipContent } from '../repositories/member-repository'
|
||||
import { getMemberSession } from '../utils/member-auth'
|
||||
import { getVerifiedMemberSession } from '../utils/member-auth'
|
||||
import {
|
||||
DEFAULT_POST_LIST_PAGE_SIZE,
|
||||
normalizeDisplayCount,
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
* @returns {Promise<Array>} 게시물 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = getMemberSession(event)
|
||||
const session = await getVerifiedMemberSession(event)
|
||||
const includeMembership = await canAccessMembershipContent(session?.userId)
|
||||
const query = getQuery(event)
|
||||
const page = normalizeListPage(query.page)
|
||||
const limit = query.limit
|
||||
? normalizeDisplayCount(query.limit, DEFAULT_POST_LIST_PAGE_SIZE)
|
||||
: null
|
||||
const offset = limit ? (page - 1) * limit : 0
|
||||
const limit = normalizeDisplayCount(query.limit, DEFAULT_POST_LIST_PAGE_SIZE)
|
||||
const offset = (page - 1) * limit
|
||||
const paginated = query.paginated === '1'
|
||||
|
||||
const result = await listPosts({
|
||||
@@ -38,7 +36,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const total = Number(result.total || 0)
|
||||
const pageSize = limit || DEFAULT_POST_LIST_PAGE_SIZE
|
||||
const pageSize = limit
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAdjacentPosts, getPostBySlug } from '../../repositories/content-repository'
|
||||
import { canAccessMembershipContent } from '../../repositories/member-repository'
|
||||
import { getMemberSession } from '../../utils/member-auth'
|
||||
import { getVerifiedMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 공개 게시물 상세 API
|
||||
@@ -9,7 +9,7 @@ import { getMemberSession } from '../../utils/member-auth'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
const session = getMemberSession(event)
|
||||
const session = await getVerifiedMemberSession(event)
|
||||
const includeMembership = await canAccessMembershipContent(session?.userId)
|
||||
const [post, adjacent] = await Promise.all([
|
||||
getPostBySlug(slug, { includeMembership }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { listPostCommentsBySlug } from '../../../repositories/comment-repository'
|
||||
import { getMemberSession } from '../../../utils/member-auth'
|
||||
import { canAccessMembershipContent } from '../../../repositories/member-repository'
|
||||
import { getVerifiedMemberSession } from '../../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 게시물 댓글 목록 조회 API
|
||||
@@ -8,8 +9,9 @@ import { getMemberSession } from '../../../utils/member-auth'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const session = getMemberSession(event)
|
||||
const comments = await listPostCommentsBySlug(slug, session?.userId || null)
|
||||
const session = await getVerifiedMemberSession(event)
|
||||
const includeMembership = await canAccessMembershipContent(session?.userId)
|
||||
const comments = await listPostCommentsBySlug(slug, session?.userId || null, includeMembership)
|
||||
|
||||
return {
|
||||
comments
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { createComment } from '../../../repositories/comment-repository'
|
||||
import { touchUserActivity } from '../../../repositories/member-repository'
|
||||
import {
|
||||
canAccessMembershipContent,
|
||||
touchUserActivity
|
||||
} from '../../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../../utils/member-auth'
|
||||
import { getClientIp } from '../../../utils/request-ip'
|
||||
|
||||
@@ -16,7 +19,8 @@ const createCommentSchema = z.object({
|
||||
* @returns {Promise<{ comment: Object }>} 생성 댓글
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const includeMembership = await canAccessMembershipContent(session.userId)
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const parsedBody = createCommentSchema.safeParse(await readBody(event))
|
||||
|
||||
@@ -31,7 +35,8 @@ export default defineEventHandler(async (event) => {
|
||||
slug,
|
||||
userId: session.userId,
|
||||
body: parsedBody.data.body,
|
||||
parentId: parsedBody.data.parentId || null
|
||||
parentId: parsedBody.data.parentId || null,
|
||||
includeMembership
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { toggleCommentLike } from '../../../../../repositories/comment-repository'
|
||||
import { touchUserActivity } from '../../../../../repositories/member-repository'
|
||||
import {
|
||||
canAccessMembershipContent,
|
||||
touchUserActivity
|
||||
} from '../../../../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../../../../utils/member-auth'
|
||||
import { getClientIp } from '../../../../../utils/request-ip'
|
||||
|
||||
@@ -9,14 +12,16 @@ import { getClientIp } from '../../../../../utils/request-ip'
|
||||
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 상태
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const session = await requireMemberSession(event)
|
||||
const includeMembership = await canAccessMembershipContent(session.userId)
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const commentId = String(getRouterParam(event, 'commentId') || '')
|
||||
|
||||
const result = await toggleCommentLike({
|
||||
slug,
|
||||
commentId,
|
||||
userId: session.userId
|
||||
userId: session.userId,
|
||||
includeMembership
|
||||
})
|
||||
|
||||
await touchUserActivity({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createError, getRequestURL } from 'h3'
|
||||
import { getAdminSession } from '../utils/admin-auth'
|
||||
import { isPrivilegedMember } from '../repositories/member-repository'
|
||||
import { clearAdminSession, getAdminSession } from '../utils/admin-auth'
|
||||
import { clearMemberSession } from '../utils/member-auth'
|
||||
import { isPrivilegedMemberSession } from '../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 관리자 API 요청마다 현재 DB 권한을 다시 확인한다.
|
||||
@@ -23,12 +24,14 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const stillPrivileged = await isPrivilegedMember(session.userId)
|
||||
const stillPrivileged = await isPrivilegedMemberSession(session.userId, session.sessionVersion)
|
||||
|
||||
if (!stillPrivileged) {
|
||||
clearAdminSession(event)
|
||||
clearMemberSession(event)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '현재 관리자 권한이 없습니다. 다시 로그인해 주세요.'
|
||||
statusCode: 401,
|
||||
message: '관리자 세션이 만료되었습니다. 다시 로그인해 주세요.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../repositories/analytics-repository'
|
||||
import { getPageBySlug } from '../repositories/content-repository'
|
||||
import { shouldExcludeAdminAnalytics } from '../utils/analytics-admin-exclusion'
|
||||
import { applySecurityHeaders } from '../utils/security-headers.js'
|
||||
|
||||
/**
|
||||
* 공개 페이지가 HTML 문서 모드일 때 Nuxt 렌더링 대신 원문 HTML을 응답한다.
|
||||
@@ -49,6 +50,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
setResponseHeader(event, 'content-type', 'text/html; charset=utf-8')
|
||||
setResponseHeader(event, 'cache-control', 'no-cache')
|
||||
applySecurityHeaders(event, { enforceCsp: false })
|
||||
|
||||
return page.content || ''
|
||||
})
|
||||
|
||||
14
server/middleware/security-headers.js
Normal file
14
server/middleware/security-headers.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getRequestURL } from 'h3'
|
||||
import { applySecurityHeaders } from '../utils/security-headers.js'
|
||||
|
||||
/**
|
||||
* 모든 응답에 브라우저 기본 보안 헤더를 적용한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const pathname = getRequestURL(event).pathname
|
||||
applySecurityHeaders(event, {
|
||||
enforceCsp: !/^\/pages(?:\/|$)/.test(pathname)
|
||||
})
|
||||
})
|
||||
29
server/plugins/post-export-worker.js
Normal file
29
server/plugins/post-export-worker.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Cron } from 'croner'
|
||||
import { queuePendingPostExportJobs } from '../repositories/post-export-repository'
|
||||
|
||||
/**
|
||||
* 대기·중단된 게시물 Export 작업 실행을 요청한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const queuePostExportWork = async () => {
|
||||
try {
|
||||
await queuePendingPostExportJobs()
|
||||
} catch (error) {
|
||||
console.error('[post-export] 작업 복구 확인 실패', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 시작과 주기 확인으로 게시물 Export 작업을 복구한다.
|
||||
*/
|
||||
export default defineNitroPlugin(() => {
|
||||
if (process.env.NUXT_POST_EXPORT_WORKER === '0' || process.env.PLAYWRIGHT_TEST === '1') {
|
||||
return
|
||||
}
|
||||
|
||||
queuePostExportWork()
|
||||
|
||||
new Cron('*/30 * * * * *', {
|
||||
protect: true
|
||||
}, queuePostExportWork)
|
||||
})
|
||||
@@ -29,18 +29,24 @@ const getSql = () => getPostgresClient()
|
||||
const isUndefinedTableError = (error) => String(error?.code || '') === '42P01'
|
||||
|
||||
/**
|
||||
* 게시물 ID 조회
|
||||
* 댓글 접근 가능한 게시물 ID 조회
|
||||
* @param {ReturnType<typeof import('postgres').default>} sql - postgres 클라이언트
|
||||
* @param {string} slug - 게시물 슬러그
|
||||
* @param {boolean} includeMembership - 멤버십 게시물 허용 여부
|
||||
* @returns {Promise<string | null>} 게시물 ID
|
||||
*/
|
||||
const findPublishedPostIdBySlug = async (sql, slug) => {
|
||||
const findAccessiblePostIdBySlug = async (sql, slug, includeMembership = false) => {
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM posts
|
||||
WHERE slug = ${slug}
|
||||
AND status = 'published'
|
||||
AND (
|
||||
status = 'published'
|
||||
OR (${includeMembership} = true AND status = 'members')
|
||||
)
|
||||
AND (
|
||||
status = 'members'
|
||||
OR
|
||||
published_at IS NULL
|
||||
OR published_at <= now()
|
||||
)
|
||||
@@ -55,14 +61,14 @@ const findPublishedPostIdBySlug = async (sql, slug) => {
|
||||
* @param {string} slug - 게시물 슬러그
|
||||
* @returns {Promise<Array<PostComment>>} 댓글 목록
|
||||
*/
|
||||
export const listPostCommentsBySlug = async (slug, viewerUserId = null) => {
|
||||
export const listPostCommentsBySlug = async (slug, viewerUserId = null, includeMembership = false) => {
|
||||
const sql = getSql()
|
||||
|
||||
if (!sql) {
|
||||
return []
|
||||
}
|
||||
|
||||
const postId = await findPublishedPostIdBySlug(sql, slug)
|
||||
const postId = await findAccessiblePostIdBySlug(sql, slug, includeMembership)
|
||||
if (!postId) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
@@ -149,7 +155,7 @@ export const listPostCommentsBySlug = async (slug, viewerUserId = null) => {
|
||||
|
||||
/**
|
||||
* 댓글 생성
|
||||
* @param {{ slug: string, userId: string, body: string, parentId?: string | null }} input - 댓글 입력값
|
||||
* @param {{ slug: string, userId: string, body: string, parentId?: string | null, includeMembership?: boolean }} input - 댓글 입력값
|
||||
* @returns {Promise<PostComment>} 생성된 댓글
|
||||
*/
|
||||
export const createComment = async (input) => {
|
||||
@@ -162,7 +168,7 @@ export const createComment = async (input) => {
|
||||
})
|
||||
}
|
||||
|
||||
const postId = await findPublishedPostIdBySlug(sql, input.slug)
|
||||
const postId = await findAccessiblePostIdBySlug(sql, input.slug, Boolean(input.includeMembership))
|
||||
if (!postId) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
@@ -250,7 +256,7 @@ export const createComment = async (input) => {
|
||||
|
||||
/**
|
||||
* 댓글 좋아요를 토글한다.
|
||||
* @param {{ slug: string, commentId: string, userId: string }} input - 좋아요 입력값
|
||||
* @param {{ slug: string, commentId: string, userId: string, includeMembership?: boolean }} input - 좋아요 입력값
|
||||
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 결과
|
||||
*/
|
||||
export const toggleCommentLike = async (input) => {
|
||||
@@ -263,7 +269,7 @@ export const toggleCommentLike = async (input) => {
|
||||
})
|
||||
}
|
||||
|
||||
const postId = await findPublishedPostIdBySlug(sql, input.slug)
|
||||
const postId = await findAccessiblePostIdBySlug(sql, input.slug, Boolean(input.includeMembership))
|
||||
if (!postId) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
|
||||
@@ -42,6 +42,71 @@ import {
|
||||
getExistingPostThumbnailUrl
|
||||
} from '../utils/post-thumbnail-image.js'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
import { replaceMediaReferences } from './media-catalog-repository'
|
||||
import { createPostSummary } from '../../composables/createPostSummary.js'
|
||||
|
||||
/**
|
||||
* 콘텐츠 저장 성공을 방해하지 않고 미디어 참조를 갱신한다.
|
||||
* @param {string} type - 참조 종류
|
||||
* @param {string} id - 콘텐츠 id
|
||||
* @param {Object} input - 저장 입력
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const syncContentMediaReferences = async (type, id, input) => {
|
||||
try {
|
||||
await replaceMediaReferences(type, id, {
|
||||
content: input.content,
|
||||
featuredImage: input.featuredImage,
|
||||
ogImage: input.ogImage
|
||||
}, {
|
||||
title: input.title,
|
||||
slug: input.slug,
|
||||
status: input.status
|
||||
})
|
||||
} catch {
|
||||
console.warn('[media-catalog] 콘텐츠 참조 갱신 실패')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정 미디어 참조를 최선 노력 방식으로 갱신한다.
|
||||
* @param {Object} settings - 현재 사이트 설정
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const syncSiteSettingsMediaReferences = async (settings) => {
|
||||
try {
|
||||
await replaceMediaReferences('site_setting', 'site', {
|
||||
logoUrl: settings.logoUrl,
|
||||
faviconUrl: settings.faviconUrl,
|
||||
homeCoverImageUrl: settings.homeCoverImageUrl,
|
||||
homeCoverDarkImageUrl: settings.homeCoverDarkImageUrl
|
||||
}, {
|
||||
title: '사이트 설정',
|
||||
status: 'system'
|
||||
})
|
||||
} catch {
|
||||
console.warn('[media-catalog] 사이트 설정 참조 갱신 실패')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 미디어 참조를 최선 노력 방식으로 갱신한다.
|
||||
* @param {Object} tag - 현재 태그
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const syncTagMediaReferences = async (tag) => {
|
||||
try {
|
||||
await replaceMediaReferences('tag', tag.id, {
|
||||
headerImageUrl: tag.headerImageUrl
|
||||
}, {
|
||||
title: tag.name,
|
||||
slug: tag.slug,
|
||||
status: tag.tagType
|
||||
})
|
||||
} catch {
|
||||
console.warn('[media-catalog] 태그 참조 갱신 실패')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 행을 API 응답 구조로 변환
|
||||
@@ -74,6 +139,23 @@ const mapPostRow = (row) => ({
|
||||
tags: row.tags || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 공개 목록 게시물에서 본문을 제외하고 표시용 요약을 만든다.
|
||||
* @param {Object} post - 게시물 응답
|
||||
* @returns {Object} 공개 목록 게시물 요약
|
||||
*/
|
||||
const mapPublicPostSummary = (post) => {
|
||||
const { content, ...summary } = post
|
||||
|
||||
return {
|
||||
...summary,
|
||||
excerpt: createPostSummary(post.excerpt, content, {
|
||||
maxLength: 320,
|
||||
appendEllipsis: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 API용 게시물 행 변환(제목 없음 플레이스홀더는 빈 문자열)
|
||||
* @param {Object} row - 게시물 행
|
||||
@@ -500,11 +582,12 @@ const syncPostTags = async (sql, postId, tags, tagNames = {}) => {
|
||||
|
||||
/**
|
||||
* 공개 게시물 목록 조회
|
||||
* @param {{ includeMembership?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션
|
||||
* @param {{ includeMembership?: boolean, includeContent?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션
|
||||
* @returns {Promise<Array|{ items: Array, total: number }>} 게시물 목록
|
||||
*/
|
||||
export const listPosts = async ({
|
||||
includeMembership = false,
|
||||
includeContent = false,
|
||||
limit = null,
|
||||
offset = 0,
|
||||
tagSlug = '',
|
||||
@@ -525,16 +608,40 @@ export const listPosts = async ({
|
||||
samplePosts = samplePosts.filter((post) => post.tags.includes(normalizedTagSlug))
|
||||
}
|
||||
const total = samplePosts.length
|
||||
const items = resolvedLimit
|
||||
const selectedItems = resolvedLimit
|
||||
? samplePosts.slice(resolvedOffset, resolvedOffset + resolvedLimit)
|
||||
: samplePosts
|
||||
const items = includeContent
|
||||
? selectedItems
|
||||
: selectedItems.map(mapPublicPostSummary)
|
||||
|
||||
return includeTotal ? { items, total } : items
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
posts.id,
|
||||
posts.title,
|
||||
posts.slug,
|
||||
posts.author_id,
|
||||
CASE
|
||||
WHEN ${includeContent} = true THEN posts.content
|
||||
ELSE LEFT(posts.content, 4000)
|
||||
END AS content,
|
||||
posts.excerpt,
|
||||
posts.featured_image,
|
||||
posts.show_featured_image,
|
||||
posts.is_featured,
|
||||
posts.admin_pinned,
|
||||
posts.seo_title,
|
||||
posts.seo_description,
|
||||
posts.canonical_url,
|
||||
posts.noindex,
|
||||
posts.og_image,
|
||||
posts.status,
|
||||
posts.published_at,
|
||||
posts.created_at,
|
||||
posts.updated_at,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM comments
|
||||
@@ -576,7 +683,10 @@ export const listPosts = async ({
|
||||
OFFSET ${resolvedOffset}
|
||||
`
|
||||
|
||||
const items = rows.map(mapPostRow)
|
||||
const mappedRows = rows.map(mapPostRow)
|
||||
const items = includeContent
|
||||
? mappedRows
|
||||
: mappedRows.map(mapPublicPostSummary)
|
||||
|
||||
if (!includeTotal) {
|
||||
return items
|
||||
@@ -1122,7 +1232,8 @@ export const createAdminPost = async (input, authorId) => {
|
||||
|
||||
await Promise.all([
|
||||
createPostThumbnailForImageUrl(input.featuredImage),
|
||||
createPostBodyThumbnailsForContent(input.content)
|
||||
createPostBodyThumbnailsForContent(input.content),
|
||||
syncContentMediaReferences('post', rows[0].id, input)
|
||||
])
|
||||
|
||||
return getAdminPostById(rows[0].id)
|
||||
@@ -1178,7 +1289,8 @@ export const updateAdminPost = async (id, input, editorId) => {
|
||||
if (rows[0]) {
|
||||
await Promise.all([
|
||||
createPostThumbnailForImageUrl(input.featuredImage),
|
||||
createPostBodyThumbnailsForContent(input.content)
|
||||
createPostBodyThumbnailsForContent(input.content),
|
||||
syncContentMediaReferences('post', rows[0].id, input)
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1838,7 +1950,10 @@ export const createAdminPage = async (input) => {
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
await createPageCoverThumbnailForImageUrl(input.featuredImage)
|
||||
await Promise.all([
|
||||
createPageCoverThumbnailForImageUrl(input.featuredImage),
|
||||
syncContentMediaReferences('page', rows[0].id, input)
|
||||
])
|
||||
|
||||
return mapPageRow(rows[0])
|
||||
}
|
||||
@@ -1877,7 +1992,10 @@ export const updateAdminPage = async (id, input) => {
|
||||
`
|
||||
|
||||
if (rows[0]) {
|
||||
await createPageCoverThumbnailForImageUrl(input.featuredImage)
|
||||
await Promise.all([
|
||||
createPageCoverThumbnailForImageUrl(input.featuredImage),
|
||||
syncContentMediaReferences('page', rows[0].id, input)
|
||||
])
|
||||
}
|
||||
|
||||
return rows[0] ? mapPageRow(rows[0]) : null
|
||||
@@ -2315,7 +2433,85 @@ export const updateSiteSettings = async (input) => {
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return mapSiteSettingsRow(rows[0])
|
||||
const settings = mapSiteSettingsRow(rows[0])
|
||||
await syncSiteSettingsMediaReferences(settings)
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* 전달된 사이트 설정 필드만 원자적으로 수정한다.
|
||||
* @param {Object} input - 부분 사이트 설정 입력값
|
||||
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||
*/
|
||||
export const updateSiteSettingsPartial = async (input) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const hasOwn = (key) => Object.prototype.hasOwnProperty.call(input, key)
|
||||
const values = {}
|
||||
|
||||
if (hasOwn('title')) values.title = input.title
|
||||
if (hasOwn('description')) values.description = input.description
|
||||
if (hasOwn('siteUrl')) values.site_url = input.siteUrl
|
||||
if (hasOwn('logoText')) values.logo_text = input.logoText || '井'
|
||||
if (hasOwn('logoUrl')) values.logo_url = input.logoUrl || ''
|
||||
if (hasOwn('faviconUrl')) values.favicon_url = input.faviconUrl || ''
|
||||
if (hasOwn('copyrightText')) values.copyright_text = input.copyrightText
|
||||
if (hasOwn('socialLinks')) values.social_links = sql.json(normalizeSocialLinks(input.socialLinks))
|
||||
if (hasOwn('showPostUpdatedAt')) values.show_post_updated_at = Boolean(input.showPostUpdatedAt)
|
||||
if (hasOwn('postTagLimit')) values.post_tag_limit = normalizePostTagLimit(input.postTagLimit)
|
||||
if (hasOwn('homeLatestPostLimit')) values.home_latest_post_limit = normalizeDisplayCount(input.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
|
||||
if (hasOwn('postListPageSize')) values.post_list_page_size = normalizeDisplayCount(input.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
|
||||
if (hasOwn('tagPostListPageSize')) values.tag_post_list_page_size = normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
||||
if (hasOwn('featuredPostLimit')) values.featured_post_limit = normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
||||
if (hasOwn('pageSectionItemLimit')) values.page_section_item_limit = normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
||||
if (hasOwn('uploadMaxFileCount')) values.upload_max_file_count = normalizeUploadMaxFileCount(input.uploadMaxFileCount)
|
||||
if (hasOwn('uploadMaxTotalSize')) values.upload_max_total_size = normalizeUploadMaxTotalSize(input.uploadMaxTotalSize)
|
||||
if (hasOwn('homeCoverImageUrl')) values.home_cover_image_url = input.homeCoverImageUrl || ''
|
||||
if (hasOwn('homeCoverDarkImageUrl')) values.home_cover_dark_image_url = input.homeCoverDarkImageUrl || ''
|
||||
if (hasOwn('homeCoverTitle')) values.home_cover_title = input.homeCoverTitle || ''
|
||||
if (hasOwn('homeCoverText')) values.home_cover_text = input.homeCoverText || ''
|
||||
if (hasOwn('brandColor')) values.brand_color = normalizeBrandColor(input.brandColor)
|
||||
if (hasOwn('announcementEnabled')) values.announcement_enabled = Boolean(input.announcementEnabled)
|
||||
if (hasOwn('announcementText')) values.announcement_text = input.announcementText || ''
|
||||
if (hasOwn('announcementUrl')) values.announcement_url = input.announcementUrl || ''
|
||||
if (hasOwn('announcementBackgroundColor')) values.announcement_background_color = normalizeAnnouncementBackgroundColor(input.announcementBackgroundColor)
|
||||
if (hasOwn('announcementAlignment')) values.announcement_alignment = normalizeAnnouncementAlignment(input.announcementAlignment)
|
||||
if (hasOwn('signupBlockedUsernames')) values.signup_blocked_usernames = JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))
|
||||
if (hasOwn('adsTxt')) values.ads_txt = input.adsTxt || ''
|
||||
if (hasOwn('customHeadCode')) values.custom_head_code = input.customHeadCode || ''
|
||||
if (hasOwn('customFooterCode')) values.custom_footer_code = input.customFooterCode || ''
|
||||
if (hasOwn('adHomeFeedCode')) values.ad_home_feed_code = input.adHomeFeedCode || ''
|
||||
if (hasOwn('adHomeInfeedCode')) values.ad_home_infeed_code = input.adHomeInfeedCode || ''
|
||||
if (hasOwn('adSidebarCode')) values.ad_sidebar_code = input.adSidebarCode || ''
|
||||
if (hasOwn('adPostSidebarCode')) values.ad_post_sidebar_code = input.adPostSidebarCode || ''
|
||||
if (hasOwn('adPostTopCode')) values.ad_post_top_code = input.adPostTopCode || ''
|
||||
if (hasOwn('adPostInArticleCode')) values.ad_post_in_article_code = input.adPostInArticleCode || ''
|
||||
if (hasOwn('adPostBottomCode')) values.ad_post_bottom_code = input.adPostBottomCode || ''
|
||||
|
||||
if (!Object.keys(values).length) {
|
||||
return getSiteSettings()
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE site_settings
|
||||
SET
|
||||
${sql(values)},
|
||||
updated_at = now()
|
||||
WHERE id = 1
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
if (!rows[0]) {
|
||||
throw new Error('SITE_SETTINGS_NOT_FOUND')
|
||||
}
|
||||
|
||||
const settings = mapSiteSettingsRow(rows[0])
|
||||
await syncSiteSettingsMediaReferences(settings)
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2351,7 +2547,9 @@ export const updateSiteLogo = async (input) => {
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return mapSiteSettingsRow(rows[0])
|
||||
const settings = mapSiteSettingsRow(rows[0])
|
||||
await syncSiteSettingsMediaReferences(settings)
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2384,7 +2582,9 @@ export const updateSiteHomeCoverImage = async (input) => {
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return mapSiteSettingsRow(rows[0])
|
||||
const settings = mapSiteSettingsRow(rows[0])
|
||||
await syncSiteSettingsMediaReferences(settings)
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2613,7 +2813,9 @@ export const createAdminTag = async (input) => {
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return mapTagRow(rows[0])
|
||||
const tag = mapTagRow(rows[0])
|
||||
await syncTagMediaReferences(tag)
|
||||
return tag
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2646,7 +2848,13 @@ export const updateAdminTag = async (id, input) => {
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return rows[0] ? mapTagRow(rows[0]) : null
|
||||
if (!rows[0]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tag = mapTagRow(rows[0])
|
||||
await syncTagMediaReferences(tag)
|
||||
return tag
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
50
server/repositories/csp-report-repository.js
Normal file
50
server/repositories/csp-report-repository.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getPostgresClient } from './postgres-client.js'
|
||||
|
||||
/**
|
||||
* CSP 위반을 원본 없이 정규화된 차원별로 집계한다.
|
||||
* @param {Array<{ policyVersion: string, documentPath: string, effectiveDirective: string, blockedOrigin: string, userAgentFamily: string }>} reports - CSP 집계 행
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const recordCspReports = async (reports) => {
|
||||
const sql = getPostgresClient()
|
||||
if (!sql || reports.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await sql.begin(async (transaction) => {
|
||||
for (const report of reports) {
|
||||
await transaction`
|
||||
INSERT INTO csp_violation_aggregates (
|
||||
policy_version,
|
||||
document_path,
|
||||
effective_directive,
|
||||
blocked_origin,
|
||||
user_agent_family,
|
||||
violation_count,
|
||||
first_seen_at,
|
||||
last_seen_at
|
||||
)
|
||||
VALUES (
|
||||
${report.policyVersion},
|
||||
${report.documentPath},
|
||||
${report.effectiveDirective},
|
||||
${report.blockedOrigin},
|
||||
${report.userAgentFamily},
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (
|
||||
policy_version,
|
||||
document_path,
|
||||
effective_directive,
|
||||
blocked_origin,
|
||||
user_agent_family
|
||||
)
|
||||
DO UPDATE SET
|
||||
violation_count = csp_violation_aggregates.violation_count + 1,
|
||||
last_seen_at = now()
|
||||
`
|
||||
}
|
||||
})
|
||||
}
|
||||
1166
server/repositories/media-catalog-repository.js
Normal file
1166
server/repositories/media-catalog-repository.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,7 @@ const mapAdminMemberRow = (row) => {
|
||||
* @property {string} avatarUrl - 아바타 URL
|
||||
* @property {boolean} isAdmin - 관리자 여부
|
||||
* @property {'owner' | 'admin' | 'vip' | 'member'} role - 권한 코드
|
||||
* @property {number} sessionVersion - 세션 무효화 버전
|
||||
* @property {string} createdAt - 생성 시각(ISO)
|
||||
* @property {string} updatedAt - 수정 시각(ISO)
|
||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||
@@ -169,6 +170,7 @@ export const getUserByEmail = async (email) => {
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -313,6 +315,7 @@ export const createUser = async (input) => {
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -367,6 +370,7 @@ export const upsertBootstrapOwner = async (input) => {
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ${input.passwordHash},
|
||||
session_version = session_version + 1,
|
||||
is_admin = true,
|
||||
user_role = ${MEMBER_ROLE.OWNER},
|
||||
updated_at = now()
|
||||
@@ -379,6 +383,7 @@ export const upsertBootstrapOwner = async (input) => {
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -406,6 +411,7 @@ export const upsertBootstrapOwner = async (input) => {
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -480,6 +486,7 @@ export const updateMemberPasswordByEmail = async (input) => {
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ${input.passwordHash},
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE lower(email) = lower(${input.email})
|
||||
RETURNING id
|
||||
@@ -499,6 +506,7 @@ export const updateMemberPassword = async (input) => {
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ${input.passwordHash},
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = ${input.userId}
|
||||
`
|
||||
@@ -787,6 +795,7 @@ export const getAdminUserByEmail = async (email) => {
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
session_version AS "sessionVersion",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -826,16 +835,37 @@ export const getMemberBootstrapState = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한을 가진 회원 여부를 확인한다.
|
||||
* 회원 세션 버전이 현재 DB 값과 일치하는지 확인한다.
|
||||
* @param {string} userId - 사용자 ID
|
||||
* @returns {Promise<boolean>} 관리자 권한 여부
|
||||
* @param {number} sessionVersion - 세션 토큰 버전
|
||||
* @returns {Promise<boolean>} 현재 세션 여부
|
||||
*/
|
||||
export const isPrivilegedMember = async (userId) => {
|
||||
export const isMemberSessionCurrent = async (userId, sessionVersion) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id = ${userId}
|
||||
AND session_version = ${sessionVersion}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한과 세션 버전을 함께 확인한다.
|
||||
* @param {string} userId - 사용자 ID
|
||||
* @param {number} sessionVersion - 세션 토큰 버전
|
||||
* @returns {Promise<boolean>} 현재 관리자 세션 여부
|
||||
*/
|
||||
export const isPrivilegedMemberSession = async (userId, sessionVersion) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id = ${userId}
|
||||
AND session_version = ${sessionVersion}
|
||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import postgres from 'postgres'
|
||||
import { getRuntimeEnvValue } from '../utils/runtime-env'
|
||||
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
|
||||
|
||||
let client = null
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsert
|
||||
import { setMemberSession } from '../../../../utils/member-auth'
|
||||
import { getRuntimeEnvValue } from '../../../../utils/runtime-env'
|
||||
import { getClientIp } from '../../../../utils/request-ip'
|
||||
import {
|
||||
assertLoginAttemptAllowed,
|
||||
clearLoginAccountFailures,
|
||||
recordLoginFailure
|
||||
} from '../../../../utils/login-attempt-limit'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -64,6 +69,15 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const emailNorm = body.email.trim().toLowerCase()
|
||||
const ip = getClientIp(event)
|
||||
const attempt = {
|
||||
scope: 'admin',
|
||||
email: emailNorm,
|
||||
ip
|
||||
}
|
||||
|
||||
assertLoginAttemptAllowed(event, attempt)
|
||||
|
||||
const adminUser = await getAdminUserByEmail(body.email) || await createBootstrapAdminUser(body)
|
||||
const passwordMatched = adminUser
|
||||
@@ -71,23 +85,27 @@ export default defineEventHandler(async (event) => {
|
||||
: false
|
||||
|
||||
if (!adminUser || !passwordMatched) {
|
||||
recordLoginFailure(attempt)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
clearLoginAccountFailures(attempt)
|
||||
setAdminSession(event, {
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email
|
||||
email: adminUser.email,
|
||||
sessionVersion: adminUser.sessionVersion
|
||||
})
|
||||
setMemberSession(event, {
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email
|
||||
email: adminUser.email,
|
||||
sessionVersion: adminUser.sessionVersion
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: adminUser.id,
|
||||
ip: getClientIp(event)
|
||||
ip
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,7 +17,9 @@ export default defineEventHandler(async (event) => {
|
||||
const member = await getMemberForAdmin(session.userId)
|
||||
|
||||
return {
|
||||
...session,
|
||||
userId: session.userId,
|
||||
email: session.email,
|
||||
role: session.role,
|
||||
roleCode: member?.roleCode || 'admin',
|
||||
roleLabel: member?.role || '관리자'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { listMediaItems } from '../../../utils/media-library'
|
||||
import { getMediaCatalogMode } from '../../../utils/media-catalog-mode'
|
||||
import {
|
||||
getMediaCatalogReadiness,
|
||||
listMediaCatalogItems,
|
||||
listPagedMediaCatalogItems
|
||||
} from '../../../repositories/media-catalog-repository'
|
||||
import {
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
@@ -172,7 +178,7 @@ const countMediaKinds = (items) => items.reduce((counts, item) => {
|
||||
* @param {Record<string, unknown>} query - 요청 쿼리
|
||||
* @returns {Object} 페이지 미디어 응답
|
||||
*/
|
||||
const createPagedMediaResponse = (items, query) => {
|
||||
export const createPagedMediaResponse = (items, query) => {
|
||||
const tab = normalizeMediaTab(query.tab)
|
||||
const pageSize = normalizeMediaPageSize(query.pageSize)
|
||||
const requestedPage = Math.max(1, Number(query.page) || 1)
|
||||
@@ -220,6 +226,17 @@ const createPagedMediaResponse = (items, query) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 목록의 민감정보 없는 비교 수치를 만든다.
|
||||
* @param {Array<Object>} items - 미디어 항목 목록
|
||||
* @returns {{ count: number, bytes: number, kinds: Record<string, number> }} 비교 수치
|
||||
*/
|
||||
const createMediaComparisonSummary = (items) => ({
|
||||
count: items.length,
|
||||
bytes: items.reduce((total, item) => total + Number(item.size || 0), 0),
|
||||
kinds: countMediaKinds(items)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 미디어 목록 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -228,10 +245,53 @@ const createPagedMediaResponse = (items, query) => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const items = await listMediaItems()
|
||||
const mode = getMediaCatalogMode()
|
||||
const query = getQuery(event)
|
||||
const paged = query.paged === '1' || query.paged === 'true'
|
||||
let items
|
||||
|
||||
if (query.paged === '1' || query.paged === 'true') {
|
||||
if (mode === 'database') {
|
||||
try {
|
||||
const readiness = await getMediaCatalogReadiness()
|
||||
|
||||
if (!readiness.ready) {
|
||||
console.warn('[media-catalog] DB 카탈로그 준비 미완료, 파일시스템으로 대체', {
|
||||
reason: readiness.reason
|
||||
})
|
||||
throw new Error('MEDIA_CATALOG_NOT_READY')
|
||||
}
|
||||
|
||||
if (paged) {
|
||||
return await listPagedMediaCatalogItems(query)
|
||||
}
|
||||
|
||||
items = await listMediaCatalogItems()
|
||||
} catch {
|
||||
console.warn('[media-catalog] DB 목록 조회 실패, 파일시스템으로 대체')
|
||||
items = await listMediaItems()
|
||||
}
|
||||
} else {
|
||||
items = await listMediaItems()
|
||||
|
||||
if (mode === 'shadow') {
|
||||
listMediaCatalogItems().then((catalogItems) => {
|
||||
const filesystem = createMediaComparisonSummary(items)
|
||||
const catalog = createMediaComparisonSummary(catalogItems)
|
||||
console.info('[media-catalog] shadow 차이', {
|
||||
count: catalog.count - filesystem.count,
|
||||
bytes: catalog.bytes - filesystem.bytes,
|
||||
kinds: Object.fromEntries(Object.keys(filesystem.kinds).map((kind) => [
|
||||
kind,
|
||||
Number(catalog.kinds[kind] || 0) - Number(filesystem.kinds[kind] || 0)
|
||||
]))
|
||||
})
|
||||
}).catch(() => {
|
||||
console.warn('[media-catalog] shadow 비교 실패')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (paged) {
|
||||
return createPagedMediaResponse(items, query)
|
||||
}
|
||||
|
||||
|
||||
14
server/routes/admin/api/media/reconcile.post.js
Normal file
14
server/routes/admin/api/media/reconcile.post.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { reconcileMediaCatalog } from '../../../../repositories/media-catalog-repository'
|
||||
|
||||
/**
|
||||
* 관리자 수동 미디어 카탈로그 재동기화 API
|
||||
* 파일을 삭제하거나 이동하지 않고 발견 파일 upsert와 누락 상태 표시만 수행한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 재동기화 비교 보고
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
return reconcileMediaCatalog()
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createPostBodyThumbnailForImageUrl,
|
||||
createPostThumbnailForImageUrl
|
||||
} from '../../../../utils/post-thumbnail-image.js'
|
||||
import { invalidateMediaItemsCache } from '../../../../utils/media-library.js'
|
||||
|
||||
/**
|
||||
* 이미지 파생 파일 재생성 API
|
||||
@@ -33,6 +34,8 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
invalidateMediaItemsCache()
|
||||
|
||||
return {
|
||||
thumbnailUrl
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getMemberForAdmin, updateMemberAvatarByAdmin } from '../../../../../rep
|
||||
import { removeManagedAvatarAsset } from '../../../../../utils/member-avatar'
|
||||
import { uploadMemberAvatarImage } from '../../../../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../../utils/media-library'
|
||||
import { replaceMediaReferences } from '../../../../../repositories/media-catalog-repository'
|
||||
|
||||
/**
|
||||
* 관리자 회원 썸네일 업로드 API
|
||||
@@ -44,6 +45,13 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
|
||||
await replaceMediaReferences('user', memberId, {
|
||||
avatarUrl
|
||||
}, {
|
||||
title: updated.username
|
||||
}).catch(() => {
|
||||
console.warn('[media-catalog] 회원 아바타 참조 갱신 실패')
|
||||
})
|
||||
|
||||
if (member.avatarUrl && member.avatarUrl !== avatarUrl) {
|
||||
await removeManagedAvatarAsset(member.avatarUrl)
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
} from 'h3'
|
||||
import { requireAdminSession } from '../../../../../../utils/admin-auth'
|
||||
import { getReadyPostExportFile } from '../../../../../../repositories/post-export-repository'
|
||||
import { getUploadRoot } from '../../../../../../utils/upload-root'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const uploadRoot = getUploadRoot()
|
||||
|
||||
/**
|
||||
* Export 파일 경로를 안전하게 해석한다.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createError } from 'h3'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { importPostsFromExportZip } from '../../../../repositories/post-import-repository'
|
||||
import { saveMultipartFileToDisk } from '../../../../utils/multipart-file-upload'
|
||||
|
||||
const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||
|
||||
@@ -11,43 +15,53 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminSession = requireAdminSession(event)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
||||
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Import할 ZIP 파일을 선택해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!String(file.filename || '').toLowerCase().endsWith('.zip')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'ZIP 파일만 Import할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!file.data?.length) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (file.data.length > MAX_IMPORT_ZIP_BYTES) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'sori-studio-import-'))
|
||||
const zipFilePath = join(tempDirectory, 'upload.zip')
|
||||
|
||||
try {
|
||||
const uploaded = await saveMultipartFileToDisk(event, {
|
||||
fieldName: 'file',
|
||||
filePath: zipFilePath,
|
||||
maxBytes: MAX_IMPORT_ZIP_BYTES
|
||||
})
|
||||
|
||||
if (!String(uploaded.filename || '').toLowerCase().endsWith('.zip')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'ZIP 파일만 Import할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!uploaded.size) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return await importPostsFromExportZip({
|
||||
zipBuffer: file.data,
|
||||
zipFilePath,
|
||||
authorId: adminSession.userId
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error?.code === 'MULTIPART_FILE_TOO_LARGE') {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (['MULTIPART_FILE_NOT_FOUND', 'INVALID_MULTIPART_REQUEST'].includes(error?.code)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Import할 ZIP 파일을 선택해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (error?.message === 'IMPORT_MARKDOWN_NOT_FOUND') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
@@ -62,6 +76,30 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if ([
|
||||
'ZIP_ENTRY_COUNT_LIMIT_EXCEEDED',
|
||||
'ZIP_ENTRY_SIZE_LIMIT_EXCEEDED',
|
||||
'ZIP_TOTAL_SIZE_LIMIT_EXCEEDED',
|
||||
'ZIP_COMPRESSION_RATIO_LIMIT_EXCEEDED',
|
||||
'IMPORT_MARKDOWN_SIZE_LIMIT_EXCEEDED'
|
||||
].includes(error?.message)) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: 'ZIP 내부 파일의 개수·압축률 또는 압축 해제 크기가 허용 범위를 초과했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if ([
|
||||
'INVALID_ZIP_FILE',
|
||||
'UNSAFE_ZIP_ENTRY_PATH',
|
||||
'DUPLICATE_ZIP_ENTRY_PATH'
|
||||
].includes(error?.message)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '안전하게 처리할 수 없는 ZIP 파일입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (error?.code === '23505') {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
@@ -73,5 +111,10 @@ export default defineEventHandler(async (event) => {
|
||||
statusCode: 500,
|
||||
message: '게시물 Import를 완료하지 못했습니다.'
|
||||
})
|
||||
} finally {
|
||||
await rm(tempDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
93
server/routes/admin/api/settings.patch.js
Normal file
93
server/routes/admin/api/settings.patch.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { parseAdminSiteSettingsInput } from '../../../utils/admin-site-settings-input'
|
||||
import {
|
||||
getSiteSettings,
|
||||
updateSiteSettingsPartial
|
||||
} from '../../../repositories/content-repository'
|
||||
|
||||
const ALLOWED_SITE_SETTINGS_FIELDS = new Set([
|
||||
'title',
|
||||
'description',
|
||||
'siteUrl',
|
||||
'logoText',
|
||||
'logoUrl',
|
||||
'faviconUrl',
|
||||
'copyrightText',
|
||||
'socialLinks',
|
||||
'showPostUpdatedAt',
|
||||
'postTagLimit',
|
||||
'homeLatestPostLimit',
|
||||
'postListPageSize',
|
||||
'tagPostListPageSize',
|
||||
'featuredPostLimit',
|
||||
'pageSectionItemLimit',
|
||||
'uploadMaxFileCount',
|
||||
'uploadMaxTotalSize',
|
||||
'homeCoverImageUrl',
|
||||
'homeCoverDarkImageUrl',
|
||||
'homeCoverTitle',
|
||||
'homeCoverText',
|
||||
'brandColor',
|
||||
'announcementEnabled',
|
||||
'announcementText',
|
||||
'announcementUrl',
|
||||
'announcementBackgroundColor',
|
||||
'announcementAlignment',
|
||||
'signupBlockedUsernames',
|
||||
'adsTxt',
|
||||
'customHeadCode',
|
||||
'customFooterCode',
|
||||
'adHomeFeedCode',
|
||||
'adHomeInfeedCode',
|
||||
'adSidebarCode',
|
||||
'adPostSidebarCode',
|
||||
'adPostTopCode',
|
||||
'adPostInArticleCode',
|
||||
'adPostBottomCode'
|
||||
])
|
||||
|
||||
/**
|
||||
* 관리자 사이트 설정의 전달된 필드만 수정한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '사이트 설정 입력 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const fields = Object.keys(body)
|
||||
if (!fields.length || fields.some((field) => !ALLOWED_SITE_SETTINGS_FIELDS.has(field))) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '수정할 수 없는 사이트 설정 필드가 포함되어 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const currentSettings = await getSiteSettings()
|
||||
const parsedBody = parseAdminSiteSettingsInput({
|
||||
...currentSettings,
|
||||
...body
|
||||
})
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '사이트 설정 입력 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const validatedPatch = Object.fromEntries(fields.map((field) => [
|
||||
field,
|
||||
parsedBody.data[field]
|
||||
]))
|
||||
|
||||
return updateSiteSettingsPartial(validatedPatch)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
|
||||
|
||||
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
@@ -65,7 +66,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
|
||||
const assetSuffix = createSystemAssetSuffix()
|
||||
const coverFileName = `home-cover-${assetSuffix}.webp`
|
||||
const coverPath = join(directoryPath, coverFileName)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
|
||||
|
||||
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
@@ -87,7 +88,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
|
||||
const assetSuffix = createSystemAssetSuffix()
|
||||
const logoFileName = `logo-${assetSuffix}.webp`
|
||||
const faviconFileName = `favicon-${assetSuffix}.png`
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
|
||||
|
||||
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
const tagCoverWidth = 720
|
||||
@@ -64,7 +65,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
|
||||
const assetSuffix = createSystemAssetSuffix()
|
||||
const coverFileName = `tag-cover-${assetSuffix}.webp`
|
||||
const coverPath = join(directoryPath, coverFileName)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { link, mkdir, mkdtemp, rm, unlink } from 'node:fs/promises'
|
||||
import { extname, join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import { createError } from 'h3'
|
||||
import {
|
||||
buildDefaultUploadSizeLimits,
|
||||
formatUploadSizeLimit,
|
||||
getUploadFilesTotalSize,
|
||||
getMaxUploadBytesForKind,
|
||||
getUploadKind,
|
||||
getUploadKindLabel,
|
||||
@@ -14,6 +13,9 @@ import {
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
|
||||
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
|
||||
import { deleteMediaCatalogEntries } from '../../../repositories/media-catalog-repository'
|
||||
import { saveMultipartFilesToDisk } from '../../../utils/multipart-file-upload'
|
||||
import { getConfiguredUploadRoot } from '../../../utils/upload-root'
|
||||
import { getSiteSettings } from '../../../repositories/content-repository'
|
||||
|
||||
const allowedUploadTypes = new Map([
|
||||
@@ -72,7 +74,7 @@ const sanitizePathPart = (value) => String(value || '')
|
||||
|
||||
/**
|
||||
* 파일 확장자 조회
|
||||
* @param {Object} file - multipart 파일 파트
|
||||
* @param {{ filename: string, type: string }} file - multipart 파일 정보
|
||||
* @returns {string} 확장자
|
||||
*/
|
||||
const getUploadExtension = (file) => {
|
||||
@@ -87,20 +89,21 @@ const getUploadExtension = (file) => {
|
||||
|
||||
/**
|
||||
* 업로드 허용 파일인지 확인한다.
|
||||
* @param {Object} file - multipart 파일 파트
|
||||
* @param {{ filename: string, type: string }} file - multipart 파일 정보
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
const isAllowedUploadFile = (file) => allowedUploadTypes.has(file.type)
|
||||
|| allowedUploadExtensions.has(extname(file.filename || '').toLowerCase())
|
||||
|
||||
/**
|
||||
* 디렉터리 안에서 비어 있는 저장 파일명을 고른다. 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||
* 임시 파일을 충돌하지 않는 최종 파일명으로 원자적으로 이동한다.
|
||||
* @param {string} directoryPath - 저장 디렉터리 절대 경로
|
||||
* @param {string} stem - 확장자 제외 파일명
|
||||
* @param {string} extension - 확장자(점 포함, 예: `.png`)
|
||||
* @param {string} sourcePath - 임시 파일 절대 경로
|
||||
* @returns {Promise<{ fileName: string, filePath: string }>} 선택된 파일명과 절대 경로
|
||||
*/
|
||||
const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
|
||||
const moveToUniqueDiskFileName = async (directoryPath, stem, extension, sourcePath) => {
|
||||
let suffix = 1
|
||||
|
||||
while (suffix < 10000) {
|
||||
@@ -108,13 +111,17 @@ const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
|
||||
const filePath = join(directoryPath, fileName)
|
||||
|
||||
try {
|
||||
await stat(filePath)
|
||||
suffix += 1
|
||||
} catch {
|
||||
await link(sourcePath, filePath)
|
||||
await unlink(sourcePath)
|
||||
return {
|
||||
fileName,
|
||||
filePath
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
suffix += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,79 +149,120 @@ export default defineEventHandler(async (event) => {
|
||||
const settings = await getSiteSettings()
|
||||
const uploadMaxFileCount = normalizeUploadMaxFileCount(settings.uploadMaxFileCount)
|
||||
const uploadMaxTotalSize = normalizeUploadMaxTotalSize(settings.uploadMaxTotalSize)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const files = (formData || []).filter((part) => part.name === 'files' && part.filename)
|
||||
|
||||
if (!files.length) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드할 파일이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length > uploadMaxFileCount) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount}개입니다.`
|
||||
})
|
||||
}
|
||||
|
||||
const uploadTotalSize = getUploadFilesTotalSize(files)
|
||||
|
||||
if (uploadTotalSize > uploadMaxTotalSize) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize)})`
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'posts', year, month)
|
||||
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'posts', year, month)
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
const tempDirectoryPath = await mkdtemp(join(directoryPath, '.upload-'))
|
||||
const movedFilePaths = []
|
||||
const catalogRecordedUrls = []
|
||||
|
||||
const uploadedFiles = []
|
||||
try {
|
||||
const streamedFiles = await saveMultipartFilesToDisk(event, {
|
||||
fieldName: 'files',
|
||||
maxFiles: uploadMaxFileCount,
|
||||
maxTotalBytes: uploadMaxTotalSize,
|
||||
createFileTarget: async (file, index) => {
|
||||
const uploadFile = {
|
||||
filename: file.filename,
|
||||
type: file.mimeType
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!isAllowedUploadFile(file)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '지원하지 않는 파일 형식입니다.'
|
||||
if (!isAllowedUploadFile(uploadFile)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '지원하지 않는 파일 형식입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const uploadKind = getUploadKind(uploadFile.type, uploadFile.filename)
|
||||
const maxFileSize = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits)
|
||||
const originalStem = sanitizePathPart(uploadFile.filename.replace(/\.[^.]+$/g, '')) || 'file'
|
||||
const extension = getUploadExtension(uploadFile)
|
||||
|
||||
return {
|
||||
filePath: join(tempDirectoryPath, `${index}${extension}`),
|
||||
maxBytes: maxFileSize,
|
||||
metadata: {
|
||||
originalStem,
|
||||
extension,
|
||||
uploadKind
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const uploadedFiles = []
|
||||
|
||||
for (const file of streamedFiles) {
|
||||
const { fileName, filePath } = await moveToUniqueDiskFileName(
|
||||
directoryPath,
|
||||
file.metadata.originalStem,
|
||||
file.metadata.extension,
|
||||
file.filePath
|
||||
)
|
||||
movedFilePaths.push(filePath)
|
||||
|
||||
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
|
||||
await upsertMediaMetadataCategory(publicUrl, '미분류')
|
||||
catalogRecordedUrls.push(publicUrl)
|
||||
|
||||
uploadedFiles.push({
|
||||
url: publicUrl,
|
||||
name: fileName,
|
||||
size: file.size
|
||||
})
|
||||
}
|
||||
|
||||
const uploadKind = getUploadKind(file.type, file.filename)
|
||||
const maxFileSize = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits)
|
||||
return {
|
||||
files: uploadedFiles
|
||||
}
|
||||
} catch (error) {
|
||||
await Promise.all(movedFilePaths.map((filePath) => unlink(filePath).catch(() => {})))
|
||||
await deleteMediaCatalogEntries(catalogRecordedUrls).catch(() => {
|
||||
console.warn('[media-catalog] 업로드 실패 정리 중 카탈로그 갱신 실패')
|
||||
})
|
||||
|
||||
if (file.data.length > maxFileSize) {
|
||||
if (error?.statusCode) {
|
||||
throw error
|
||||
}
|
||||
if (error?.code === 'MULTIPART_TOO_MANY_FILES') {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: `${getUploadKindLabel(uploadKind)} 업로드 가능 크기를 초과했습니다. (최대 ${formatUploadSizeLimit(maxFileSize)})`
|
||||
message: `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount}개입니다.`
|
||||
})
|
||||
}
|
||||
if (error?.code === 'MULTIPART_TOTAL_SIZE_TOO_LARGE') {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize)})`
|
||||
})
|
||||
}
|
||||
if (error?.code === 'MULTIPART_FILE_TOO_LARGE') {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: `${getUploadKindLabel(error.metadata?.uploadKind)} 업로드 가능 크기를 초과했습니다. (최대 ${formatUploadSizeLimit(error.maxBytes)})`
|
||||
})
|
||||
}
|
||||
if (['MULTIPART_FILE_NOT_FOUND', 'INVALID_MULTIPART_REQUEST', 'MULTIPART_TOO_MANY_PARTS'].includes(error?.code)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드할 파일이 없거나 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image'
|
||||
const extension = getUploadExtension(file)
|
||||
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, originalStem, extension)
|
||||
|
||||
await writeFile(filePath, file.data)
|
||||
|
||||
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
|
||||
|
||||
await upsertMediaMetadataCategory(publicUrl, '미분류')
|
||||
|
||||
uploadedFiles.push({
|
||||
url: publicUrl,
|
||||
name: fileName,
|
||||
size: file.data.length
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '파일을 업로드하지 못했습니다.'
|
||||
})
|
||||
} finally {
|
||||
await rm(tempDirectoryPath, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
files: uploadedFiles
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
setResponseHeader,
|
||||
setResponseStatus
|
||||
} from 'h3'
|
||||
import { getUploadRoot } from '../../utils/upload-root'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const uploadRoot = getUploadRoot()
|
||||
const contentTypes = new Map([
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
|
||||
@@ -51,7 +51,7 @@ const signPayload = (payload) => createHmac('sha256', getSessionSecret())
|
||||
|
||||
/**
|
||||
* 관리자 세션 토큰 생성
|
||||
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
|
||||
* @param {{ userId: string, email: string, sessionVersion?: number }} adminUser - 관리자 사용자 정보
|
||||
* @returns {string} 세션 토큰
|
||||
*/
|
||||
export const createAdminSessionToken = (adminUser) => {
|
||||
@@ -59,6 +59,7 @@ export const createAdminSessionToken = (adminUser) => {
|
||||
userId: adminUser.userId,
|
||||
email: adminUser.email,
|
||||
role: 'admin',
|
||||
sessionVersion: Number.isInteger(adminUser.sessionVersion) ? adminUser.sessionVersion : 1,
|
||||
expiresAt: Date.now() + sessionMaxAge * 1000
|
||||
})).toString('base64url')
|
||||
|
||||
@@ -68,7 +69,7 @@ export const createAdminSessionToken = (adminUser) => {
|
||||
/**
|
||||
* 관리자 세션 토큰 검증
|
||||
* @param {string | undefined} token - 세션 토큰
|
||||
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
|
||||
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number } | null} 세션 정보
|
||||
*/
|
||||
export const verifyAdminSessionToken = (token) => {
|
||||
if (!token) {
|
||||
@@ -96,14 +97,17 @@ export const verifyAdminSessionToken = (token) => {
|
||||
return {
|
||||
userId: session.userId,
|
||||
email: session.email,
|
||||
role: 'admin'
|
||||
role: 'admin',
|
||||
sessionVersion: Number.isInteger(session.sessionVersion) && session.sessionVersion > 0
|
||||
? session.sessionVersion
|
||||
: 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 세션 쿠키 설정
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
|
||||
* @param {{ userId: string, email: string, sessionVersion?: number }} adminUser - 관리자 사용자 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setAdminSession = (event, adminUser) => {
|
||||
@@ -130,14 +134,14 @@ export const clearAdminSession = (event) => {
|
||||
/**
|
||||
* 관리자 세션 조회
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
|
||||
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number } | null} 세션 정보
|
||||
*/
|
||||
export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName))
|
||||
|
||||
/**
|
||||
* 관리자 세션 필수 확인
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ userId: string, email: string, role: 'admin' }} 세션 정보
|
||||
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number }} 세션 정보
|
||||
*/
|
||||
export const requireAdminSession = (event) => {
|
||||
const session = getAdminSession(event)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
recordAnalyticsHeartbeat
|
||||
} from '../repositories/analytics-repository.js'
|
||||
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
|
||||
import { shouldAcceptAnalyticsRequest } from './analytics-request-limit.js'
|
||||
|
||||
const heartbeatInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
@@ -36,6 +37,10 @@ export const handleAnalyticsHeartbeat = async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (!shouldAcceptAnalyticsRequest(event, 'heartbeat')) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const userAgent = String(getRequestHeader(event, 'user-agent') || '')
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
recordAnalyticsPageview
|
||||
} from '../repositories/analytics-repository.js'
|
||||
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
|
||||
import { shouldAcceptAnalyticsRequest } from './analytics-request-limit.js'
|
||||
|
||||
const pageviewInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
@@ -40,6 +41,10 @@ export const handleAnalyticsPageview = async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (!shouldAcceptAnalyticsRequest(event, 'pageview')) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const userAgent = String(getRequestHeader(event, 'user-agent') || '')
|
||||
|
||||
|
||||
71
server/utils/analytics-request-limit.js
Normal file
71
server/utils/analytics-request-limit.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { getClientIp } from './request-ip'
|
||||
|
||||
const ANALYTICS_WINDOW_MS = 60 * 1000
|
||||
const ANALYTICS_PAGEVIEW_LIMIT = 120
|
||||
const ANALYTICS_HEARTBEAT_LIMIT = 240
|
||||
const ANALYTICS_BUCKET_LIMIT = 20000
|
||||
const analyticsBuckets = new Map()
|
||||
let lastCleanupAt = 0
|
||||
|
||||
/**
|
||||
* 통계 제한 키에 사용할 IP 해시를 생성한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {'pageview'|'heartbeat'} kind - 통계 요청 종류
|
||||
* @returns {string} 제한 버킷 키
|
||||
*/
|
||||
const createAnalyticsLimitKey = (event, kind) => createHash('sha256')
|
||||
.update(`${kind}:${getClientIp(event)}`)
|
||||
.digest('hex')
|
||||
|
||||
/**
|
||||
* 만료된 통계 제한 버킷을 정리한다.
|
||||
* @param {number} now - 현재 시각
|
||||
* @returns {void}
|
||||
*/
|
||||
const cleanupAnalyticsBuckets = (now) => {
|
||||
if (now - lastCleanupAt < ANALYTICS_WINDOW_MS && analyticsBuckets.size < ANALYTICS_BUCKET_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCleanupAt = now
|
||||
for (const [key, bucket] of analyticsBuckets) {
|
||||
if (bucket.expiresAt <= now) {
|
||||
analyticsBuckets.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
while (analyticsBuckets.size >= ANALYTICS_BUCKET_LIMIT) {
|
||||
const oldestKey = analyticsBuckets.keys().next().value
|
||||
if (!oldestKey) {
|
||||
break
|
||||
}
|
||||
analyticsBuckets.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 IP의 통계 쓰기 요청을 허용할지 확인한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {'pageview'|'heartbeat'} kind - 통계 요청 종류
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
export const shouldAcceptAnalyticsRequest = (event, kind) => {
|
||||
const now = Date.now()
|
||||
const limit = kind === 'heartbeat' ? ANALYTICS_HEARTBEAT_LIMIT : ANALYTICS_PAGEVIEW_LIMIT
|
||||
const key = createAnalyticsLimitKey(event, kind)
|
||||
|
||||
cleanupAnalyticsBuckets(now)
|
||||
|
||||
const current = analyticsBuckets.get(key)
|
||||
if (!current || current.expiresAt <= now) {
|
||||
analyticsBuckets.set(key, {
|
||||
count: 1,
|
||||
expiresAt: now + ANALYTICS_WINDOW_MS
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
current.count += 1
|
||||
return current.count <= limit
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { extname, join, relative, sep } from 'node:path'
|
||||
import { getUploadRoot } from './upload-root'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const uploadRoot = getUploadRoot()
|
||||
const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'])
|
||||
const videoExtensions = new Set(['.mp4', '.mov', '.webm'])
|
||||
const audioExtensions = new Set(['.m4a', '.mp3', '.ogg', '.wav'])
|
||||
|
||||
77
server/utils/csp-report-request-limit.js
Normal file
77
server/utils/csp-report-request-limit.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createError, setResponseHeader } from 'h3'
|
||||
import { getClientIp } from './request-ip.js'
|
||||
|
||||
const CSP_REPORT_WINDOW_MS = 60 * 1000
|
||||
const CSP_REPORT_REQUEST_LIMIT = 60
|
||||
const CSP_REPORT_BUCKET_LIMIT = 10000
|
||||
|
||||
/** @type {Map<string, { count: number, resetAt: number }>} */
|
||||
const cspReportBuckets = new Map()
|
||||
let lastCleanupAt = 0
|
||||
|
||||
/**
|
||||
* 원문 IP를 메모리에 남기지 않는 CSP 요청 제한 키를 만든다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {string} IP 해시 키
|
||||
*/
|
||||
const createCspReportLimitKey = (event) => createHash('sha256')
|
||||
.update(`csp-report:${getClientIp(event)}`)
|
||||
.digest('base64url')
|
||||
|
||||
/**
|
||||
* 만료되거나 상한을 넘은 CSP 요청 제한 버킷을 정리한다.
|
||||
* @param {number} now - 현재 시각
|
||||
* @returns {void}
|
||||
*/
|
||||
const cleanupCspReportBuckets = (now) => {
|
||||
if (now - lastCleanupAt < CSP_REPORT_WINDOW_MS && cspReportBuckets.size < CSP_REPORT_BUCKET_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCleanupAt = now
|
||||
for (const [key, bucket] of cspReportBuckets) {
|
||||
if (bucket.resetAt <= now) {
|
||||
cspReportBuckets.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
while (cspReportBuckets.size >= CSP_REPORT_BUCKET_LIMIT) {
|
||||
const oldestKey = cspReportBuckets.keys().next().value
|
||||
if (!oldestKey) {
|
||||
break
|
||||
}
|
||||
cspReportBuckets.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 IP의 CSP 보고 요청이 분당 상한을 넘지 않았는지 확인한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
export const assertCspReportRequestAllowed = (event) => {
|
||||
const now = Date.now()
|
||||
const key = createCspReportLimitKey(event)
|
||||
cleanupCspReportBuckets(now)
|
||||
|
||||
const current = cspReportBuckets.get(key)
|
||||
if (!current || current.resetAt <= now) {
|
||||
cspReportBuckets.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + CSP_REPORT_WINDOW_MS
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
current.count += 1
|
||||
if (current.count <= CSP_REPORT_REQUEST_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'Retry-After', String(Math.max(1, Math.ceil((current.resetAt - now) / 1000))))
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: 'CSP 보고 요청이 너무 많습니다.'
|
||||
})
|
||||
}
|
||||
135
server/utils/login-attempt-limit.js
Normal file
135
server/utils/login-attempt-limit.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createError, setResponseHeader } from 'h3'
|
||||
|
||||
const LOGIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000
|
||||
const LOGIN_ACCOUNT_FAILURE_LIMIT = 5
|
||||
const LOGIN_IP_FAILURE_LIMIT = 20
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAX_ATTEMPT_BUCKET_COUNT = 10000
|
||||
|
||||
/** @type {Map<string, { count: number, resetAt: number }>} */
|
||||
const attemptBuckets = new Map()
|
||||
let lastCleanupAt = 0
|
||||
|
||||
/**
|
||||
* 로그인 제한 키에 개인정보 원문이 남지 않도록 해시한다.
|
||||
* @param {string} value - 원본 키
|
||||
* @returns {string} 해시 키
|
||||
*/
|
||||
const createAttemptKey = (value) => createHash('sha256')
|
||||
.update(String(value || ''))
|
||||
.digest('base64url')
|
||||
|
||||
/**
|
||||
* 만료된 로그인 제한 버킷을 정리한다.
|
||||
* @param {number} now - 현재 시각
|
||||
* @returns {void}
|
||||
*/
|
||||
const cleanupExpiredBuckets = (now) => {
|
||||
if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCleanupAt = now
|
||||
for (const [key, bucket] of attemptBuckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
attemptBuckets.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인 제한 버킷을 조회한다.
|
||||
* @param {string} key - 버킷 키
|
||||
* @param {number} now - 현재 시각
|
||||
* @returns {{ count: number, resetAt: number }} 버킷
|
||||
*/
|
||||
const getAttemptBucket = (key, now) => {
|
||||
const current = attemptBuckets.get(key)
|
||||
if (current && current.resetAt > now) {
|
||||
return current
|
||||
}
|
||||
|
||||
if (attemptBuckets.size >= MAX_ATTEMPT_BUCKET_COUNT) {
|
||||
const oldestKey = attemptBuckets.keys().next().value
|
||||
if (oldestKey) {
|
||||
attemptBuckets.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
const next = {
|
||||
count: 0,
|
||||
resetAt: now + LOGIN_ATTEMPT_WINDOW_MS
|
||||
}
|
||||
attemptBuckets.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 시도 제한용 계정·IP 키를 만든다.
|
||||
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
|
||||
* @returns {{ accountKey: string, ipKey: string }} 제한 키
|
||||
*/
|
||||
const createLoginAttemptKeys = (input) => ({
|
||||
accountKey: `account:${createAttemptKey(`${input.scope}:${String(input.email || '').trim().toLowerCase()}`)}`,
|
||||
ipKey: `ip:${createAttemptKey(`${input.scope}:${String(input.ip || '').trim()}`)}`
|
||||
})
|
||||
|
||||
/**
|
||||
* 로그인 요청이 현재 제한을 넘지 않았는지 확인한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
|
||||
* @returns {void}
|
||||
*/
|
||||
export const assertLoginAttemptAllowed = (event, input) => {
|
||||
const now = Date.now()
|
||||
cleanupExpiredBuckets(now)
|
||||
|
||||
const keys = createLoginAttemptKeys(input)
|
||||
const accountBucket = attemptBuckets.get(keys.accountKey)
|
||||
const ipBucket = attemptBuckets.get(keys.ipKey)
|
||||
const accountBlocked = Boolean(accountBucket && accountBucket.resetAt > now && accountBucket.count >= LOGIN_ACCOUNT_FAILURE_LIMIT)
|
||||
const ipBlocked = Boolean(ipBucket && ipBucket.resetAt > now && ipBucket.count >= LOGIN_IP_FAILURE_LIMIT)
|
||||
|
||||
if (!accountBlocked && !ipBlocked) {
|
||||
return
|
||||
}
|
||||
|
||||
const retryAt = Math.max(
|
||||
accountBlocked ? accountBucket.resetAt : 0,
|
||||
ipBlocked ? ipBucket.resetAt : 0
|
||||
)
|
||||
const retryAfter = Math.max(1, Math.ceil((retryAt - now) / 1000))
|
||||
setResponseHeader(event, 'Retry-After', String(retryAfter))
|
||||
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패한 로그인 시도를 계정과 IP 기준으로 기록한다.
|
||||
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
|
||||
* @returns {void}
|
||||
*/
|
||||
export const recordLoginFailure = (input) => {
|
||||
const now = Date.now()
|
||||
cleanupExpiredBuckets(now)
|
||||
const keys = createLoginAttemptKeys(input)
|
||||
|
||||
for (const key of [keys.accountKey, keys.ipKey]) {
|
||||
const bucket = getAttemptBucket(key, now)
|
||||
bucket.count += 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인에 성공한 계정의 실패 기록을 제거한다.
|
||||
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
|
||||
* @returns {void}
|
||||
*/
|
||||
export const clearLoginAccountFailures = (input) => {
|
||||
const keys = createLoginAttemptKeys(input)
|
||||
attemptBuckets.delete(keys.accountKey)
|
||||
}
|
||||
22
server/utils/media-catalog-mode.js
Normal file
22
server/utils/media-catalog-mode.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 미디어 카탈로그 조회 모드를 환경 변수로 해석한다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 카탈로그 모드 문자열을 허용 값으로 정규화한다.
|
||||
* @param {unknown} value - 원본 모드
|
||||
* @returns {'filesystem'|'shadow'|'database'|null} 정규화된 모드
|
||||
*/
|
||||
export const normalizeMediaCatalogMode = (value) => {
|
||||
const mode = String(value || '').trim().toLowerCase()
|
||||
|
||||
return ['filesystem', 'shadow', 'database'].includes(mode) ? mode : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 적용 중인 미디어 카탈로그 모드를 반환한다.
|
||||
* @returns {'filesystem'|'shadow'|'database'} 조회 모드
|
||||
*/
|
||||
export const getMediaCatalogMode = () => {
|
||||
return normalizeMediaCatalogMode(process.env.MEDIA_CATALOG_MODE) || 'filesystem'
|
||||
}
|
||||
@@ -5,9 +5,14 @@ import {
|
||||
getSiteSettings,
|
||||
listAdminTags,
|
||||
listMediaReferencePages,
|
||||
listMediaReferencePosts
|
||||
listMediaReferencePosts,
|
||||
listNavigationItems
|
||||
} from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
import {
|
||||
deleteMediaCatalogEntries,
|
||||
recordMediaCatalogFile,
|
||||
} from '../repositories/media-catalog-repository'
|
||||
import {
|
||||
getPageCoverThumbnailUrl,
|
||||
getLegacyPostBodyThumbnailUrl,
|
||||
@@ -21,13 +26,30 @@ import {
|
||||
isPostCardThumbnailUrl,
|
||||
isPostThumbnailSource
|
||||
} from './post-thumbnail-image.js'
|
||||
import { getUploadRoot, resolveUploadUrlPath } from './upload-root'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const uploadRoot = getUploadRoot()
|
||||
const mediaFilePattern = /\.(jpe?g|png|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|pdf|zip|txt|csv|docx|xlsx|pptx)$/i
|
||||
const MEDIA_ITEMS_CACHE_TTL_MS = 30 * 1000
|
||||
let mediaItemsCache = null
|
||||
let mediaItemsCacheExpiresAt = 0
|
||||
let mediaItemsLoadPromise = null
|
||||
let mediaItemsCacheRevision = 0
|
||||
|
||||
/** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */
|
||||
export const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||
|
||||
/**
|
||||
* 미디어 목록 캐시를 무효화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const invalidateMediaItemsCache = () => {
|
||||
mediaItemsCacheRevision += 1
|
||||
mediaItemsCache = null
|
||||
mediaItemsCacheExpiresAt = 0
|
||||
mediaItemsLoadPromise = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 아바타 공개 URL 여부
|
||||
* @param {string} url - 미디어 URL
|
||||
@@ -197,9 +219,10 @@ const getAvatarOwnersByUrls = async (urls) => {
|
||||
* 미디어 논리 폴더 메타를 upsert한다.
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {string} category - 논리 폴더명
|
||||
* @param {{ origin?: string, role?: string }} [options] - 카탈로그 기록 옵션
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const upsertMediaMetadataCategory = async (url, category) => {
|
||||
export const upsertMediaMetadataCategory = async (url, category, options = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
@@ -207,21 +230,24 @@ export const upsertMediaMetadataCategory = async (url, category) => {
|
||||
}
|
||||
|
||||
const normalizedCategory = normalizeMediaCategory(category)
|
||||
const catalogRecorded = await recordMediaCatalogFile(url, {
|
||||
category: normalizedCategory,
|
||||
...options
|
||||
})
|
||||
|
||||
await sql`
|
||||
INSERT INTO media_metadata (
|
||||
url,
|
||||
category
|
||||
)
|
||||
VALUES (
|
||||
${url},
|
||||
${normalizedCategory}
|
||||
)
|
||||
ON CONFLICT (url) DO UPDATE
|
||||
SET
|
||||
category = EXCLUDED.category,
|
||||
updated_at = now()
|
||||
`
|
||||
if (!catalogRecorded) {
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO media_metadata (url, category)
|
||||
VALUES (${url}, ${normalizedCategory})
|
||||
ON CONFLICT (url) DO UPDATE
|
||||
SET category = EXCLUDED.category, updated_at = now()
|
||||
`
|
||||
} catch {
|
||||
console.warn('[media-catalog] 호환 메타데이터 기록 실패')
|
||||
}
|
||||
}
|
||||
invalidateMediaItemsCache()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,13 +278,8 @@ const getMediaMetadataMap = async () => {
|
||||
*/
|
||||
export const listMediaFolders = async () => {
|
||||
const sql = getPostgresClient()
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const metadataMap = await getMediaMetadataMap()
|
||||
const defaultCategories = items.map((item) => {
|
||||
const rawCategory = metadataMap[item.url]?.category || item.category
|
||||
|
||||
return normalizeStoredDisplayCategory(item.url, rawCategory)
|
||||
})
|
||||
const items = await listMediaItems()
|
||||
const defaultCategories = items.map((item) => item.category)
|
||||
|
||||
if (!sql) {
|
||||
return excludeThumbnailFolderPaths([...new Set(['미분류', ...defaultCategories])])
|
||||
@@ -387,7 +408,7 @@ export const resolveMediaPath = (url) => {
|
||||
}
|
||||
|
||||
const decodedUrl = decodeURIComponent(url)
|
||||
const filePath = join(process.cwd(), 'public', decodedUrl)
|
||||
const filePath = resolveUploadUrlPath(decodedUrl)
|
||||
const relativePath = relative(uploadRoot, filePath)
|
||||
|
||||
if (relativePath.startsWith('..') || relativePath === '') {
|
||||
@@ -541,6 +562,27 @@ const getTagMediaUsage = (url, tags) => tags
|
||||
label: '태그 헤더 이미지'
|
||||
}))
|
||||
|
||||
/**
|
||||
* 네비게이션 추천 사이트 파비콘 사용처 조회
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {Array<Object>} navigationItems - 네비게이션 항목 목록
|
||||
* @returns {Array<Object>} 사용처 목록
|
||||
*/
|
||||
const getNavigationMediaUsage = (url, navigationItems = []) => navigationItems
|
||||
.filter((item) => item.cachedFaviconUrl === url)
|
||||
.map((item) => ({
|
||||
type: 'navigation',
|
||||
typeLabel: '네비게이션',
|
||||
id: item.id,
|
||||
title: item.label,
|
||||
slug: item.url || '',
|
||||
adminUrl: '/admin/navigation',
|
||||
publicUrl: item.url || '',
|
||||
status: 'navigation',
|
||||
location: 'cachedFaviconUrl',
|
||||
label: '추천 사이트 아이콘'
|
||||
}))
|
||||
|
||||
/**
|
||||
* 미디어 URL이 실제 사용처에서 쓰이는지 조회한다.
|
||||
* @param {string} url - 미디어 URL
|
||||
@@ -548,12 +590,14 @@ const getTagMediaUsage = (url, tags) => tags
|
||||
* @param {Array<Object>} pages - 페이지 목록
|
||||
* @param {Object} siteSettings - 사이트 설정
|
||||
* @param {Array<Object>} tags - 태그 목록
|
||||
* @param {Array<Object>} [navigationItems] - 네비게이션 항목 목록
|
||||
* @returns {Array<Object>} 사용처 목록
|
||||
*/
|
||||
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
|
||||
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = [], navigationItems = []) => [
|
||||
...getMediaUsage(url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(url, siteSettings),
|
||||
...getTagMediaUsage(url, tags)
|
||||
...getTagMediaUsage(url, tags),
|
||||
...getNavigationMediaUsage(url, navigationItems)
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -763,10 +807,11 @@ const getOriginalDerivativeMediaUsage = (
|
||||
* @param {Object} siteSettings - 사이트 설정
|
||||
* @param {Array<Object>} tags - 태그 목록
|
||||
* @param {{ thumbnailOwnerByUrl: Map<string, string>, pageCoverThumbnailOwnerByUrl: Map<string, string>, bodyThumbnailOwnerByUrl: Map<string, string> }} ownerMaps - 파생 URL별 원본 URL 맵 묶음
|
||||
* @param {Array<Object>} [navigationItems] - 네비게이션 항목 목록
|
||||
* @returns {Array<Object>} 사용처 목록
|
||||
*/
|
||||
const getOriginalMediaUsage = (url, posts, pages, siteSettings, tags, ownerMaps) => [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||
const getOriginalMediaUsage = (url, posts, pages, siteSettings, tags, ownerMaps, navigationItems = []) => [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags, navigationItems),
|
||||
...getOriginalDerivativeMediaUsage(url, ownerMaps.thumbnailOwnerByUrl, posts, pages, siteSettings, tags, {
|
||||
location: 'featuredImageThumbnail',
|
||||
label: '목록 카드 썸네일',
|
||||
@@ -856,6 +901,24 @@ const getPageCoverThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 업로드 이미지의 카드·페이지 커버 썸네일 상태를 조회한다.
|
||||
* 페이지 대표 이미지면 페이지 커버 상태를 우선한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @param {Set<string>} itemUrlSet - 전체 미디어 URL 집합
|
||||
* @param {Array<Object>} directUsage - 원본 URL 직접 사용처
|
||||
* @returns {Object|null} 썸네일 상태
|
||||
*/
|
||||
const getOriginalImageThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
const isPageCoverImage = directUsage.some((usage) => usage.type === 'page' && usage.location === 'featuredImage')
|
||||
|
||||
if (isPageCoverImage) {
|
||||
return getPageCoverThumbnailStatus(item, itemUrlSet, directUsage)
|
||||
}
|
||||
|
||||
return getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
@@ -954,17 +1017,18 @@ const getSiteSettingsMediaUsage = (url, siteSettings) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 목록 조회
|
||||
* 디스크와 콘텐츠 참조에서 미디어 목록을 새로 구성한다.
|
||||
* @returns {Promise<Array<Object>>} 미디어 항목 목록
|
||||
*/
|
||||
export const listMediaItems = async () => {
|
||||
const loadMediaItems = async () => {
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const metadataMap = await getMediaMetadataMap()
|
||||
const [posts, pages, siteSettings, tags] = await Promise.all([
|
||||
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
|
||||
listMediaReferencePosts(),
|
||||
listMediaReferencePages(),
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
listAdminTags(),
|
||||
listNavigationItems()
|
||||
])
|
||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||
@@ -980,7 +1044,7 @@ export const listMediaItems = async () => {
|
||||
const rawCategory = metadataMap[item.url]?.category ?? item.category
|
||||
const category = normalizeStoredDisplayCategory(item.url, rawCategory)
|
||||
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
|
||||
const directUsage = getOriginalMediaUsage(item.url, posts, pages, siteSettings, tags, ownerMaps)
|
||||
const directUsage = getOriginalMediaUsage(item.url, posts, pages, siteSettings, tags, ownerMaps, navigationItems)
|
||||
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const pageCoverThumbnailUsage = getPageCoverThumbnailMediaUsage(item.url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
@@ -1007,7 +1071,7 @@ export const listMediaItems = async () => {
|
||||
role: 'pageCover',
|
||||
originalUrl: pageCoverOriginalUrl
|
||||
}
|
||||
: getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage),
|
||||
: getOriginalImageThumbnailStatus(item, itemUrlSet, directUsage),
|
||||
bodyThumbnailStatus: bodyOriginalUrl
|
||||
? {
|
||||
role: 'body',
|
||||
@@ -1021,22 +1085,46 @@ export const listMediaItems = async () => {
|
||||
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* 짧은 공유 캐시를 사용해 미디어 전체 스캔의 요청별 반복을 막는다.
|
||||
* @param {{ force?: boolean }} [options] - 조회 옵션
|
||||
* @returns {Promise<Array<Object>>} 미디어 항목 목록
|
||||
*/
|
||||
export const listMediaItems = async (options = {}) => {
|
||||
const now = Date.now()
|
||||
|
||||
if (!options.force && mediaItemsCache && mediaItemsCacheExpiresAt > now) {
|
||||
return mediaItemsCache
|
||||
}
|
||||
if (!options.force && mediaItemsLoadPromise) {
|
||||
return mediaItemsLoadPromise
|
||||
}
|
||||
|
||||
const revision = mediaItemsCacheRevision
|
||||
const loadPromise = loadMediaItems().then((items) => {
|
||||
if (revision === mediaItemsCacheRevision) {
|
||||
mediaItemsCache = items
|
||||
mediaItemsCacheExpiresAt = Date.now() + MEDIA_ITEMS_CACHE_TTL_MS
|
||||
}
|
||||
return items
|
||||
}).finally(() => {
|
||||
if (mediaItemsLoadPromise === loadPromise) {
|
||||
mediaItemsLoadPromise = null
|
||||
}
|
||||
})
|
||||
|
||||
mediaItemsLoadPromise = loadPromise
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 메타데이터 삭제
|
||||
* @param {string} url - 미디어 URL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMediaMetadata = async (url) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return
|
||||
}
|
||||
|
||||
await sql`
|
||||
DELETE FROM media_metadata
|
||||
WHERE url = ${url}
|
||||
`
|
||||
await deleteMediaCatalogEntries([url])
|
||||
invalidateMediaItemsCache()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1059,6 +1147,7 @@ const moveMediaMetadata = async (currentUrl, nextUrl) => {
|
||||
updated_at = now()
|
||||
WHERE url = ${currentUrl}
|
||||
`
|
||||
invalidateMediaItemsCache()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1097,21 +1186,26 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
|
||||
for (const url of [...new Set(urls.filter(Boolean))]) {
|
||||
const mediaPath = resolveMediaPath(url)
|
||||
const catalogRecorded = await recordMediaCatalogFile(url, {
|
||||
category: normalizedCategory
|
||||
})
|
||||
|
||||
await sql`
|
||||
INSERT INTO media_metadata (
|
||||
url,
|
||||
category
|
||||
)
|
||||
VALUES (
|
||||
${url},
|
||||
${normalizedCategory}
|
||||
)
|
||||
ON CONFLICT (url) DO UPDATE
|
||||
SET
|
||||
category = EXCLUDED.category,
|
||||
updated_at = now()
|
||||
`
|
||||
if (!catalogRecorded) {
|
||||
await sql`
|
||||
INSERT INTO media_metadata (
|
||||
url,
|
||||
category
|
||||
)
|
||||
VALUES (
|
||||
${url},
|
||||
${normalizedCategory}
|
||||
)
|
||||
ON CONFLICT (url) DO UPDATE
|
||||
SET
|
||||
category = EXCLUDED.category,
|
||||
updated_at = now()
|
||||
`
|
||||
}
|
||||
|
||||
const item = await createMediaItem(mediaPath)
|
||||
items.push({
|
||||
@@ -1130,11 +1224,12 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteMediaItem = async (url) => {
|
||||
const [posts, pages, siteSettings, tags] = await Promise.all([
|
||||
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
|
||||
listMediaReferencePosts(),
|
||||
listMediaReferencePages(),
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
listAdminTags(),
|
||||
listNavigationItems()
|
||||
])
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
|
||||
@@ -1146,7 +1241,7 @@ export const deleteMediaItem = async (url) => {
|
||||
bodyThumbnailOwnerByUrl
|
||||
}
|
||||
const usage = [
|
||||
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps),
|
||||
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps, navigationItems),
|
||||
...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings),
|
||||
...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings),
|
||||
...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
@@ -1191,11 +1286,12 @@ export const renameMediaItem = async (url, name) => {
|
||||
})
|
||||
}
|
||||
|
||||
const [posts, pages, siteSettings, tags] = await Promise.all([
|
||||
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
|
||||
listMediaReferencePosts(),
|
||||
listMediaReferencePages(),
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
listAdminTags(),
|
||||
listNavigationItems()
|
||||
])
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
|
||||
@@ -1207,7 +1303,7 @@ export const renameMediaItem = async (url, name) => {
|
||||
bodyThumbnailOwnerByUrl
|
||||
}
|
||||
const usage = [
|
||||
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps),
|
||||
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps, navigationItems),
|
||||
...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings),
|
||||
...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings),
|
||||
...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
@@ -1260,6 +1356,7 @@ export const renameMediaItem = async (url, name) => {
|
||||
|
||||
const renamedItem = await createMediaItem(nextPath)
|
||||
await moveMediaMetadata(url, renamedItem.url)
|
||||
await recordMediaCatalogFile(renamedItem.url)
|
||||
|
||||
return createMediaItem(nextPath)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
|
||||
import { isMemberSessionCurrent } from '../repositories/member-repository'
|
||||
import { getRuntimeEnvValue } from './runtime-env'
|
||||
import { shouldUseSecureSessionCookie } from './session-cookie'
|
||||
|
||||
@@ -51,13 +52,14 @@ const signPayload = (payload) => createHmac('sha256', getSessionSecret())
|
||||
|
||||
/**
|
||||
* 회원 세션 토큰 생성
|
||||
* @param {{ userId: string, email: string }} user - 회원 정보
|
||||
* @param {{ userId: string, email: string, sessionVersion?: number }} user - 회원 정보
|
||||
* @returns {string} 세션 토큰
|
||||
*/
|
||||
export const createMemberSessionToken = (user) => {
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
sessionVersion: Number.isInteger(user.sessionVersion) ? user.sessionVersion : 1,
|
||||
expiresAt: Date.now() + sessionMaxAge * 1000
|
||||
})).toString('base64url')
|
||||
|
||||
@@ -67,7 +69,7 @@ export const createMemberSessionToken = (user) => {
|
||||
/**
|
||||
* 회원 세션 토큰 검증
|
||||
* @param {string | undefined} token - 세션 토큰
|
||||
* @returns {{ userId: string, email: string } | null} 세션 정보
|
||||
* @returns {{ userId: string, email: string, sessionVersion: number } | null} 세션 정보
|
||||
*/
|
||||
export const verifyMemberSessionToken = (token) => {
|
||||
if (!token) {
|
||||
@@ -94,14 +96,17 @@ export const verifyMemberSessionToken = (token) => {
|
||||
|
||||
return {
|
||||
userId: session.userId,
|
||||
email: session.email
|
||||
email: session.email,
|
||||
sessionVersion: Number.isInteger(session.sessionVersion) && session.sessionVersion > 0
|
||||
? session.sessionVersion
|
||||
: 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 쿠키 설정
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{ userId: string, email: string }} user - 회원 정보
|
||||
* @param {{ userId: string, email: string, sessionVersion?: number }} user - 회원 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setMemberSession = (event, user) => {
|
||||
@@ -128,17 +133,38 @@ export const clearMemberSession = (event) => {
|
||||
/**
|
||||
* 회원 세션 조회
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ userId: string, email: string } | null} 세션 정보
|
||||
* @returns {{ userId: string, email: string, sessionVersion: number } | null} 세션 정보
|
||||
*/
|
||||
export const getMemberSession = (event) => verifyMemberSessionToken(getCookie(event, memberSessionCookieName))
|
||||
|
||||
/**
|
||||
* DB 세션 버전까지 확인한 회원 세션을 조회한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ userId: string, email: string, sessionVersion: number } | null>} 현재 세션 정보
|
||||
*/
|
||||
export const getVerifiedMemberSession = async (event) => {
|
||||
const session = getMemberSession(event)
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCurrent = await isMemberSessionCurrent(session.userId, session.sessionVersion)
|
||||
if (!isCurrent) {
|
||||
clearMemberSession(event)
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 필수 확인
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ userId: string, email: string }} 세션 정보
|
||||
* @returns {Promise<{ userId: string, email: string, sessionVersion: number }>} 세션 정보
|
||||
*/
|
||||
export const requireMemberSession = (event) => {
|
||||
const session = getMemberSession(event)
|
||||
export const requireMemberSession = async (event) => {
|
||||
const session = await getVerifiedMemberSession(event)
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { getConfiguredUploadRoot } from './upload-root'
|
||||
|
||||
const allowedImageTypes = new Map([
|
||||
['image/jpeg', '.jpg'],
|
||||
@@ -134,7 +135,7 @@ export const uploadMemberAvatarImage = async (event) => {
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
|
||||
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'members', 'avatars', year, month)
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { join, relative } from 'node:path'
|
||||
import { createError } from 'h3'
|
||||
import { recordMediaCatalogFile } from '../repositories/media-catalog-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
import { getUploadRoot } from './upload-root'
|
||||
|
||||
const managedAvatarPrefix = '/uploads/members/avatars/'
|
||||
|
||||
@@ -25,8 +27,8 @@ export const resolveMemberAvatarPath = (url) => {
|
||||
}
|
||||
|
||||
const decodedUrl = decodeURIComponent(url)
|
||||
const filePath = join(process.cwd(), 'public', decodedUrl)
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const uploadRoot = getUploadRoot()
|
||||
const filePath = join(uploadRoot, decodedUrl.replace(/^\/uploads\//, ''))
|
||||
const relativePath = relative(uploadRoot, filePath)
|
||||
|
||||
if (relativePath.startsWith('..') || relativePath === '') {
|
||||
@@ -40,7 +42,8 @@ export const resolveMemberAvatarPath = (url) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로필에서 썸네일 URL을 끊을 때 `media_metadata` 행만 제거한다. 디스크 파일은 유지해 관리자 미디어(썸네일 탭)에서 미사용 자산으로 확인·삭제할 수 있게 한다.
|
||||
* 프로필에서 연결을 끊어도 디스크와 카탈로그 행은 유지한다.
|
||||
* 참조가 제거된 파일은 관리자 미디어의 미사용 썸네일로 계속 확인할 수 있다.
|
||||
* @param {string} url - 정리 대상 URL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@@ -50,13 +53,19 @@ export const removeManagedAvatarAsset = async (url) => {
|
||||
}
|
||||
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
return
|
||||
|
||||
if (sql) {
|
||||
await sql`
|
||||
DELETE FROM media_references
|
||||
WHERE media_url = ${url}
|
||||
AND reference_type = 'user'
|
||||
`
|
||||
}
|
||||
|
||||
await sql`
|
||||
DELETE FROM media_metadata
|
||||
WHERE url = ${url}
|
||||
`
|
||||
await recordMediaCatalogFile(url, {
|
||||
category: '썸네일',
|
||||
origin: 'upload',
|
||||
role: 'avatar'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
275
server/utils/multipart-file-upload.js
Normal file
275
server/utils/multipart-file-upload.js
Normal file
@@ -0,0 +1,275 @@
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { Transform } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import Busboy from 'busboy'
|
||||
|
||||
/**
|
||||
* 코드가 포함된 multipart 오류를 만든다.
|
||||
* @param {string} code - 오류 코드
|
||||
* @returns {Error & { code: string }} multipart 오류
|
||||
*/
|
||||
const createMultipartError = (code) => {
|
||||
const error = new Error(code)
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* busboy가 UTF-8 파일명 바이트를 Latin-1 문자열로 넘긴 경우를 복구한다.
|
||||
* 이미 유니코드 코드포인트로 해석된 파일명은 그대로 둔다.
|
||||
* @param {string|null|undefined} filename - multipart 파일명
|
||||
* @returns {string} NFC 정규화된 파일명
|
||||
*/
|
||||
export const decodeMultipartFilename = (filename) => {
|
||||
const value = String(filename || '')
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hasNonLatin1 = [...value].some((char) => Number(char.codePointAt(0)) > 255)
|
||||
|
||||
if (hasNonLatin1) {
|
||||
return value.normalize('NFC')
|
||||
}
|
||||
|
||||
const repaired = Buffer.from(value, 'latin1').toString('utf8')
|
||||
|
||||
if (!repaired.includes('\uFFFD')) {
|
||||
return repaired.normalize('NFC')
|
||||
}
|
||||
|
||||
return value.normalize('NFC')
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 파일 하나를 메모리에 올리지 않고 디스크로 저장한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{ fieldName: string, filePath: string, maxBytes: number }} input - 저장 옵션
|
||||
* @returns {Promise<{ filename: string, mimeType: string, size: number }>} 저장 결과
|
||||
*/
|
||||
export const saveMultipartFileToDisk = async (event, input) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let parser = null
|
||||
|
||||
try {
|
||||
parser = Busboy({
|
||||
headers: event.node.req.headers,
|
||||
limits: {
|
||||
files: 1,
|
||||
fields: 4,
|
||||
parts: 5,
|
||||
fileSize: input.maxBytes
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
reject(createMultipartError('INVALID_MULTIPART_REQUEST'))
|
||||
return
|
||||
}
|
||||
|
||||
let matched = false
|
||||
let truncated = false
|
||||
let filename = ''
|
||||
let mimeType = ''
|
||||
let size = 0
|
||||
/** @type {Promise<void>|null} */
|
||||
let fileWritePromise = null
|
||||
|
||||
parser.on('file', (fieldName, fileStream, info) => {
|
||||
if (fieldName !== input.fieldName || matched) {
|
||||
fileStream.resume()
|
||||
return
|
||||
}
|
||||
|
||||
matched = true
|
||||
filename = decodeMultipartFilename(info.filename)
|
||||
mimeType = String(info.mimeType || '')
|
||||
const output = createWriteStream(input.filePath, {
|
||||
flags: 'wx'
|
||||
})
|
||||
|
||||
fileStream.on('data', (chunk) => {
|
||||
size += chunk.length
|
||||
})
|
||||
fileStream.on('limit', () => {
|
||||
truncated = true
|
||||
})
|
||||
fileWritePromise = pipeline(fileStream, output)
|
||||
})
|
||||
|
||||
parser.on('filesLimit', () => {
|
||||
truncated = true
|
||||
})
|
||||
parser.on('error', async (error) => {
|
||||
if (fileWritePromise) {
|
||||
await fileWritePromise.catch(() => {})
|
||||
}
|
||||
reject(error)
|
||||
})
|
||||
parser.on('finish', async () => {
|
||||
try {
|
||||
if (fileWritePromise) {
|
||||
await fileWritePromise
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
reject(createMultipartError('MULTIPART_FILE_NOT_FOUND'))
|
||||
return
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
reject(createMultipartError('MULTIPART_FILE_TOO_LARGE'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve({
|
||||
filename,
|
||||
mimeType,
|
||||
size
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
event.node.req.on('aborted', () => {
|
||||
parser.destroy(createMultipartError('MULTIPART_REQUEST_ABORTED'))
|
||||
})
|
||||
event.node.req.pipe(parser)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 파일 여러 개를 메모리에 올리지 않고 디스크로 저장한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{
|
||||
* fieldName: string,
|
||||
* maxFiles: number,
|
||||
* maxTotalBytes: number,
|
||||
* createFileTarget: (file: { filename: string, mimeType: string }, index: number) => Promise<{ filePath: string, maxBytes: number, metadata?: Object }>
|
||||
* }} input - 저장 옵션
|
||||
* @returns {Promise<Array<{ filename: string, mimeType: string, size: number, filePath: string, metadata: Object }>>} 저장 결과
|
||||
*/
|
||||
export const saveMultipartFilesToDisk = async (event, input) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let parser = null
|
||||
|
||||
try {
|
||||
parser = Busboy({
|
||||
headers: event.node.req.headers,
|
||||
limits: {
|
||||
files: input.maxFiles,
|
||||
fields: 4,
|
||||
parts: input.maxFiles + 4,
|
||||
fileSize: input.maxTotalBytes
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
reject(createMultipartError('INVALID_MULTIPART_REQUEST'))
|
||||
return
|
||||
}
|
||||
|
||||
let matchedFileCount = 0
|
||||
let totalSize = 0
|
||||
let limitError = null
|
||||
let taskError = null
|
||||
const savedFiles = []
|
||||
const fileTasks = []
|
||||
|
||||
parser.on('file', (fieldName, fileStream, info) => {
|
||||
if (fieldName !== input.fieldName) {
|
||||
fileStream.resume()
|
||||
return
|
||||
}
|
||||
|
||||
const fileIndex = matchedFileCount
|
||||
matchedFileCount += 1
|
||||
const filename = decodeMultipartFilename(info.filename)
|
||||
const mimeType = String(info.mimeType || '')
|
||||
|
||||
const fileTask = (async () => {
|
||||
try {
|
||||
const target = await input.createFileTarget({ filename, mimeType }, fileIndex)
|
||||
let fileSize = 0
|
||||
|
||||
const sizeLimiter = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
fileSize += chunk.length
|
||||
totalSize += chunk.length
|
||||
|
||||
if (!limitError && fileSize > target.maxBytes) {
|
||||
limitError = createMultipartError('MULTIPART_FILE_TOO_LARGE')
|
||||
limitError.maxBytes = target.maxBytes
|
||||
limitError.metadata = target.metadata || {}
|
||||
}
|
||||
if (!limitError && totalSize > input.maxTotalBytes) {
|
||||
limitError = createMultipartError('MULTIPART_TOTAL_SIZE_TOO_LARGE')
|
||||
}
|
||||
|
||||
callback(null, limitError ? null : chunk)
|
||||
}
|
||||
})
|
||||
|
||||
fileStream.on('limit', () => {
|
||||
limitError = limitError || createMultipartError('MULTIPART_TOTAL_SIZE_TOO_LARGE')
|
||||
})
|
||||
await pipeline(
|
||||
fileStream,
|
||||
sizeLimiter,
|
||||
createWriteStream(target.filePath, { flags: 'wx' })
|
||||
)
|
||||
|
||||
savedFiles.push({
|
||||
filename,
|
||||
mimeType,
|
||||
size: fileSize,
|
||||
filePath: target.filePath,
|
||||
metadata: target.metadata || {},
|
||||
index: fileIndex
|
||||
})
|
||||
} catch (error) {
|
||||
taskError = taskError || error
|
||||
fileStream.resume()
|
||||
}
|
||||
})()
|
||||
|
||||
fileTasks.push(fileTask)
|
||||
})
|
||||
|
||||
parser.on('filesLimit', () => {
|
||||
limitError = limitError || createMultipartError('MULTIPART_TOO_MANY_FILES')
|
||||
})
|
||||
parser.on('partsLimit', () => {
|
||||
limitError = limitError || createMultipartError('MULTIPART_TOO_MANY_PARTS')
|
||||
})
|
||||
parser.on('error', async (error) => {
|
||||
await Promise.allSettled(fileTasks)
|
||||
reject(error)
|
||||
})
|
||||
parser.on('finish', async () => {
|
||||
await Promise.all(fileTasks)
|
||||
|
||||
if (taskError) {
|
||||
reject(taskError)
|
||||
return
|
||||
}
|
||||
if (limitError) {
|
||||
reject(limitError)
|
||||
return
|
||||
}
|
||||
if (!matchedFileCount) {
|
||||
reject(createMultipartError('MULTIPART_FILE_NOT_FOUND'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(savedFiles
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map(({ index: _index, ...file }) => file))
|
||||
})
|
||||
|
||||
event.node.req.on('aborted', () => {
|
||||
parser.destroy(createMultipartError('MULTIPART_REQUEST_ABORTED'))
|
||||
})
|
||||
event.node.req.pipe(parser)
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl
|
||||
} from '../../lib/post-card-thumbnail-url.js'
|
||||
import { recordMediaCatalogFile } from '../repositories/media-catalog-repository'
|
||||
import { resolveUploadUrlPath } from './upload-root'
|
||||
|
||||
export {
|
||||
getPageCoverThumbnailUrl,
|
||||
@@ -183,7 +185,7 @@ export const getPostThumbnailDiskPath = (imageUrl) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +200,7 @@ export const getPostBodyThumbnailDiskPath = (imageUrl) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,7 +215,7 @@ export const getPostBodyPreviewThumbnailDiskPath = (imageUrl) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,7 +230,7 @@ export const getPostLightboxThumbnailDiskPath = (imageUrl) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +245,7 @@ export const getPageCoverThumbnailDiskPath = (imageUrl) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,7 +258,7 @@ export const getPostImageDiskPath = (imageUrl) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(imageUrl.replace(/^\/+/, '')))
|
||||
return resolveUploadUrlPath(decodeUrlPathPart(imageUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,6 +386,13 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
|
||||
.webp({ quality: POST_THUMBNAIL_QUALITY })
|
||||
.toFile(temporaryPath))
|
||||
|
||||
if (created) {
|
||||
await recordMediaCatalogFile(thumbnailUrl, {
|
||||
origin: 'derived',
|
||||
role: 'card'
|
||||
})
|
||||
}
|
||||
|
||||
return created ? thumbnailUrl : ''
|
||||
}
|
||||
|
||||
@@ -419,6 +428,13 @@ export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
|
||||
.webp({ quality: PAGE_COVER_THUMBNAIL_QUALITY })
|
||||
.toFile(temporaryPath))
|
||||
|
||||
if (created) {
|
||||
await recordMediaCatalogFile(thumbnailUrl, {
|
||||
origin: 'derived',
|
||||
role: 'page_cover'
|
||||
})
|
||||
}
|
||||
|
||||
return created ? thumbnailUrl : ''
|
||||
}
|
||||
|
||||
@@ -481,6 +497,21 @@ export const createPostBodyThumbnailForImageUrl = async (imageUrl) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
recordMediaCatalogFile(thumbnailUrl, {
|
||||
origin: 'derived',
|
||||
role: 'body'
|
||||
}),
|
||||
recordMediaCatalogFile(getPostBodyPreviewThumbnailUrl(imageUrl), {
|
||||
origin: 'derived',
|
||||
role: 'body'
|
||||
}),
|
||||
recordMediaCatalogFile(getPostLightboxThumbnailUrl(imageUrl), {
|
||||
origin: 'derived',
|
||||
role: 'body'
|
||||
})
|
||||
])
|
||||
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateNavigationItemFaviconCache
|
||||
} from '../repositories/content-repository.js'
|
||||
import { upsertMediaMetadataCategory } from './media-library.js'
|
||||
import { getConfiguredUploadRoot } from './upload-root'
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15000
|
||||
const FAVICON_OUTPUT_SIZE = 64
|
||||
@@ -89,7 +90,7 @@ const fetchFaviconBytes = async (sourceUrl) => {
|
||||
const writeRecommendedFaviconFile = async (itemId, bytes) => {
|
||||
const uploadBaseUrl = getUploadBaseUrl()
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
|
||||
const shortId = String(itemId).replace(/-/g, '').slice(0, 8)
|
||||
const fileName = `recommended-favicon-${shortId}-${createFaviconAssetSuffix()}.png`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
|
||||
@@ -118,7 +118,11 @@ const buildRssItem = (post, siteUrl) => {
|
||||
export const buildRssFeed = async (selfPath = '/rss.xml', fallbackSiteUrl = '') => {
|
||||
const [settings, posts] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
listPosts({ includeMembership: false })
|
||||
listPosts({
|
||||
includeMembership: false,
|
||||
includeContent: true,
|
||||
limit: RSS_POST_LIMIT
|
||||
})
|
||||
])
|
||||
const siteUrl = normalizeSiteUrl(settings.siteUrl || fallbackSiteUrl)
|
||||
const feedUrl = `${siteUrl}${selfPath}`
|
||||
|
||||
70
server/utils/security-headers.js
Normal file
70
server/utils/security-headers.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { getRequestURL, setResponseHeader } from 'h3'
|
||||
|
||||
const enforcedContentSecurityPolicy = [
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'self'"
|
||||
].join('; ')
|
||||
|
||||
const contentSecurityPolicyReportOnly = [
|
||||
"default-src 'self' https: data: blob:",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https:",
|
||||
"style-src 'self' 'unsafe-inline' https:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data: https:",
|
||||
"media-src 'self' blob: https:",
|
||||
"frame-src https:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"worker-src 'self' blob: https:",
|
||||
"manifest-src 'self' https:",
|
||||
'report-uri /api/csp-report',
|
||||
'report-to csp-endpoint'
|
||||
].join('; ')
|
||||
|
||||
/**
|
||||
* 현재 요청과 같은 origin의 CSP 보고 URL을 안전하게 만든다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {string} CSP 보고 URL
|
||||
*/
|
||||
const getCspReportEndpoint = (event) => {
|
||||
try {
|
||||
const requestUrl = getRequestURL(event)
|
||||
if (!['http:', 'https:'].includes(requestUrl.protocol)) {
|
||||
return '/api/csp-report'
|
||||
}
|
||||
|
||||
const endpoint = new URL('/api/csp-report', requestUrl.origin).toString()
|
||||
return /["\\\r\n]/.test(endpoint) ? '/api/csp-report' : endpoint
|
||||
} catch {
|
||||
return '/api/csp-report'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 보안 헤더와 호환성 관찰 CSP를 적용한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{ enforceCsp?: boolean }} options - 강제 CSP 적용 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
export const applySecurityHeaders = (event, options = {}) => {
|
||||
const reportEndpoint = getCspReportEndpoint(event)
|
||||
|
||||
setResponseHeader(event, 'X-Content-Type-Options', 'nosniff')
|
||||
setResponseHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
setResponseHeader(event, 'X-Frame-Options', 'SAMEORIGIN')
|
||||
setResponseHeader(event, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||
setResponseHeader(event, 'Reporting-Endpoints', `csp-endpoint="${reportEndpoint}"`)
|
||||
setResponseHeader(event, 'Report-To', JSON.stringify({
|
||||
group: 'csp-endpoint',
|
||||
max_age: 10886400,
|
||||
endpoints: [{ url: reportEndpoint }]
|
||||
}))
|
||||
setResponseHeader(event, 'Content-Security-Policy-Report-Only', contentSecurityPolicyReportOnly)
|
||||
|
||||
if (options.enforceCsp !== false) {
|
||||
setResponseHeader(event, 'Content-Security-Policy', enforcedContentSecurityPolicy)
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,10 @@ const buildUrlItem = ({ loc, lastmod = '', changefreq = '', priority = '' }) =>
|
||||
export const buildSitemap = async (fallbackSiteUrl = '') => {
|
||||
const [settings, posts, pages, tags] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
listPosts({ includeMembership: false }),
|
||||
listPosts({
|
||||
includeMembership: false,
|
||||
limit: 50000
|
||||
}),
|
||||
listPages(),
|
||||
listTags()
|
||||
])
|
||||
|
||||
45
server/utils/upload-root.js
Normal file
45
server/utils/upload-root.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* 런타임 업로드 파일 루트를 반환한다.
|
||||
* Playwright 실행은 명시된 임시 디렉터리만 사용한다.
|
||||
* @returns {string} 업로드 파일 절대 경로
|
||||
*/
|
||||
export const getUploadRoot = () => {
|
||||
const testUploadRoot = String(process.env.SORI_TEST_UPLOAD_ROOT || '').trim()
|
||||
|
||||
if (process.env.PLAYWRIGHT_TEST === '1') {
|
||||
if (!testUploadRoot) {
|
||||
throw new Error('SORI_TEST_UPLOAD_ROOT_REQUIRED')
|
||||
}
|
||||
|
||||
return resolve(testUploadRoot)
|
||||
}
|
||||
|
||||
return resolve(process.cwd(), 'public', 'uploads')
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 공개 업로드 경로의 디스크 루트를 반환한다.
|
||||
* @param {string} publicBasePath - `uploads` 형태 공개 경로
|
||||
* @returns {string} 업로드 디렉터리 절대 경로
|
||||
*/
|
||||
export const getConfiguredUploadRoot = (publicBasePath = 'uploads') => {
|
||||
if (process.env.PLAYWRIGHT_TEST === '1') {
|
||||
return getUploadRoot()
|
||||
}
|
||||
|
||||
return resolve(process.cwd(), 'public', publicBasePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* `/uploads/...` URL을 현재 실행 환경의 파일 경로로 변환한다.
|
||||
* @param {string} uploadUrl - 업로드 공개 URL
|
||||
* @returns {string} 파일 절대 경로
|
||||
*/
|
||||
export const resolveUploadUrlPath = (uploadUrl) => {
|
||||
const decodedPath = decodeURIComponent(String(uploadUrl || ''))
|
||||
.replace(/^\/+uploads\/?/, '')
|
||||
|
||||
return resolve(getUploadRoot(), decodedPath)
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { inflateRawSync } from 'node:zlib'
|
||||
import unzipper from 'unzipper'
|
||||
|
||||
const DEFAULT_MAX_ENTRY_COUNT = 5000
|
||||
const DEFAULT_MAX_ENTRY_BYTES = 300 * 1024 * 1024
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
||||
const DEFAULT_MAX_COMPRESSION_RATIO = 250
|
||||
|
||||
/**
|
||||
* ZIP 엔트리 경로를 안전하게 정리한다.
|
||||
@@ -13,111 +18,116 @@ const normalizeZipEntryPath = (value) => String(value || '')
|
||||
.join('/')
|
||||
|
||||
/**
|
||||
* ZIP 종료 레코드 위치를 찾는다.
|
||||
* @param {Buffer} buffer - ZIP 파일 버퍼
|
||||
* @returns {number} 종료 레코드 위치
|
||||
* ZIP 내부 경로가 상위 폴더나 절대 경로를 가리키는지 확인한다.
|
||||
* @param {string} value - 원본 ZIP 경로
|
||||
* @returns {boolean} 안전하지 않은 경로 여부
|
||||
*/
|
||||
const findEndOfCentralDirectoryOffset = (buffer) => {
|
||||
const signature = 0x06054b50
|
||||
const minOffset = Math.max(0, buffer.length - 65557)
|
||||
const isUnsafeZipEntryPath = (value) => {
|
||||
const normalized = String(value || '').replace(/\\/g, '/')
|
||||
return normalized.startsWith('/')
|
||||
|| normalized.includes('\0')
|
||||
|| normalized.split('/').some((part) => part === '..')
|
||||
}
|
||||
|
||||
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
|
||||
if (buffer.readUInt32LE(offset) === signature) {
|
||||
return offset
|
||||
/**
|
||||
* ZIP 엔트리 스트림을 제한된 크기의 Buffer로 읽는다.
|
||||
* @param {NodeJS.ReadableStream} stream - 엔트리 스트림
|
||||
* @param {number} maxBytes - 최대 허용 바이트
|
||||
* @returns {Promise<Buffer>} 엔트리 데이터
|
||||
*/
|
||||
const readStreamBuffer = async (stream, maxBytes) => {
|
||||
const chunks = []
|
||||
let totalBytes = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
totalBytes += buffer.length
|
||||
|
||||
if (totalBytes > maxBytes) {
|
||||
stream.destroy()
|
||||
throw new Error('ZIP_ENTRY_SIZE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
chunks.push(buffer)
|
||||
}
|
||||
|
||||
return -1
|
||||
return Buffer.concat(chunks, totalBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 엔트리 데이터를 압축 해제한다.
|
||||
* @param {Buffer} buffer - 전체 ZIP 버퍼
|
||||
* @param {Object} entry - 중앙 디렉터리 엔트리
|
||||
* @returns {Buffer} 압축 해제된 데이터
|
||||
* ZIP 중앙 디렉터리를 읽고 엔트리 수·크기·압축률을 검증한다.
|
||||
* 실제 파일 데이터는 필요할 때 각 엔트리 스트림으로 읽는다.
|
||||
* @param {string} zipFilePath - ZIP 파일 절대 경로
|
||||
* @param {{ maxEntryCount?: number, maxEntryBytes?: number, maxTotalBytes?: number, maxCompressionRatio?: number }} [options] - 제한 옵션
|
||||
* @returns {Promise<Array<{ path: string, compressedSize: number, uncompressedSize: number, readBuffer: (maxBytes?: number) => Promise<Buffer>, createReadStream: () => NodeJS.ReadableStream }>>} ZIP 엔트리 목록
|
||||
*/
|
||||
const readZipEntryData = (buffer, entry) => {
|
||||
const localHeaderOffset = entry.localHeaderOffset
|
||||
export const openZipFileEntries = async (zipFilePath, options = {}) => {
|
||||
const maxEntryCount = Number(options.maxEntryCount) || DEFAULT_MAX_ENTRY_COUNT
|
||||
const maxEntryBytes = Number(options.maxEntryBytes) || DEFAULT_MAX_ENTRY_BYTES
|
||||
const maxTotalBytes = Number(options.maxTotalBytes) || DEFAULT_MAX_TOTAL_BYTES
|
||||
const maxCompressionRatio = Number(options.maxCompressionRatio) || DEFAULT_MAX_COMPRESSION_RATIO
|
||||
let directory = null
|
||||
|
||||
if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) {
|
||||
throw new Error('INVALID_ZIP_LOCAL_HEADER')
|
||||
}
|
||||
|
||||
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
|
||||
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
|
||||
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength
|
||||
const compressedData = buffer.subarray(dataOffset, dataOffset + entry.compressedSize)
|
||||
|
||||
if (entry.compressionMethod === 0) {
|
||||
return Buffer.from(compressedData)
|
||||
}
|
||||
|
||||
if (entry.compressionMethod === 8) {
|
||||
return inflateRawSync(compressedData)
|
||||
}
|
||||
|
||||
throw new Error('UNSUPPORTED_ZIP_COMPRESSION')
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 파일 버퍼를 파일 엔트리 목록으로 읽는다.
|
||||
* @param {Buffer} buffer - ZIP 파일 버퍼
|
||||
* @returns {Array<{ path: string, data: Buffer }>} 파일 엔트리 목록
|
||||
*/
|
||||
export const readZipBufferEntries = (buffer) => {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 22) {
|
||||
try {
|
||||
directory = await unzipper.Open.file(zipFilePath)
|
||||
} catch {
|
||||
throw new Error('INVALID_ZIP_FILE')
|
||||
}
|
||||
|
||||
const endOffset = findEndOfCentralDirectoryOffset(buffer)
|
||||
const sourceEntries = directory.files.filter((entry) => entry.type !== 'Directory')
|
||||
|
||||
if (endOffset < 0) {
|
||||
throw new Error('INVALID_ZIP_FILE')
|
||||
}
|
||||
|
||||
const entryCount = buffer.readUInt16LE(endOffset + 10)
|
||||
const centralDirectorySize = buffer.readUInt32LE(endOffset + 12)
|
||||
const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16)
|
||||
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize
|
||||
|
||||
if (centralDirectoryOffset < 0 || centralDirectoryEnd > buffer.length) {
|
||||
throw new Error('INVALID_ZIP_FILE')
|
||||
if (sourceEntries.length > maxEntryCount) {
|
||||
throw new Error('ZIP_ENTRY_COUNT_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
const entries = []
|
||||
let offset = centralDirectoryOffset
|
||||
const usedPaths = new Set()
|
||||
let totalUncompressedBytes = 0
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (buffer.readUInt32LE(offset) !== 0x02014b50) {
|
||||
throw new Error('INVALID_ZIP_CENTRAL_DIRECTORY')
|
||||
for (const sourceEntry of sourceEntries) {
|
||||
const rawPath = String(sourceEntry.path || '')
|
||||
if (isUnsafeZipEntryPath(rawPath)) {
|
||||
throw new Error('UNSAFE_ZIP_ENTRY_PATH')
|
||||
}
|
||||
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 10)
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20)
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 24)
|
||||
const nameLength = buffer.readUInt16LE(offset + 28)
|
||||
const extraLength = buffer.readUInt16LE(offset + 30)
|
||||
const commentLength = buffer.readUInt16LE(offset + 32)
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
|
||||
const rawPath = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
||||
const normalizedPath = normalizeZipEntryPath(rawPath)
|
||||
const isDirectory = rawPath.replace(/\\/g, '/').endsWith('/')
|
||||
|
||||
if (normalizedPath && !isDirectory && !normalizedPath.startsWith('__MACOSX/')) {
|
||||
entries.push({
|
||||
path: normalizedPath,
|
||||
compressionMethod,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset
|
||||
})
|
||||
if (!normalizedPath || normalizedPath.startsWith('__MACOSX/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
offset += 46 + nameLength + extraLength + commentLength
|
||||
if (usedPaths.has(normalizedPath)) {
|
||||
throw new Error('DUPLICATE_ZIP_ENTRY_PATH')
|
||||
}
|
||||
usedPaths.add(normalizedPath)
|
||||
|
||||
const compressedSize = Number(sourceEntry.compressedSize || 0)
|
||||
const uncompressedSize = Number(sourceEntry.uncompressedSize || 0)
|
||||
const compressionRatio = uncompressedSize / Math.max(compressedSize, 1)
|
||||
|
||||
if (uncompressedSize < 0 || uncompressedSize > maxEntryBytes) {
|
||||
throw new Error('ZIP_ENTRY_SIZE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
if (uncompressedSize > 1024 * 1024 && compressionRatio > maxCompressionRatio) {
|
||||
throw new Error('ZIP_COMPRESSION_RATIO_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
totalUncompressedBytes += uncompressedSize
|
||||
if (totalUncompressedBytes > maxTotalBytes) {
|
||||
throw new Error('ZIP_TOTAL_SIZE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
entries.push({
|
||||
path: normalizedPath,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
readBuffer: (readMaxBytes = maxEntryBytes) => readStreamBuffer(
|
||||
sourceEntry.stream(),
|
||||
Math.min(readMaxBytes, maxEntryBytes)
|
||||
),
|
||||
createReadStream: () => sourceEntry.stream()
|
||||
})
|
||||
}
|
||||
|
||||
return entries.map((entry) => ({
|
||||
path: entry.path,
|
||||
data: readZipEntryData(buffer, entry)
|
||||
}))
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -1,167 +1,95 @@
|
||||
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
|
||||
})
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { ZipArchive } from 'archiver'
|
||||
|
||||
/**
|
||||
* 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 엔트리 경로를 정리한다.
|
||||
* ZIP 엔트리 경로를 안전하게 정리한다.
|
||||
* @param {string} pathValue - 엔트리 경로
|
||||
* @returns {string} 정리된 경로
|
||||
*/
|
||||
const normalizeZipPath = (pathValue) => String(pathValue || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/g, '')
|
||||
.replace(/\.\.+/g, '.')
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.' && part !== '..')
|
||||
.join('/')
|
||||
|
||||
/**
|
||||
* ZIP 로컬 파일 헤더를 만든다.
|
||||
* @param {Object} entry - 엔트리 정보
|
||||
* @returns {Buffer} 로컬 파일 헤더
|
||||
* ZIP 파일을 디스크로 스트리밍 생성한다.
|
||||
* 이미 압축된 이미지·영상·첨부 파일은 원본 바이트를 유지한 채 store 방식으로 담는다.
|
||||
* @param {string} outputPath - 생성할 ZIP 절대 경로
|
||||
* @param {Array<{ path: string, data?: Buffer|string, sourcePath?: string, store?: boolean }>} files - ZIP 엔트리
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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)
|
||||
export const writeZipFile = async (outputPath, files) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(outputPath, {
|
||||
flags: 'wx'
|
||||
})
|
||||
const archive = new ZipArchive({
|
||||
zlib: {
|
||||
level: 6
|
||||
},
|
||||
forceZip64: true
|
||||
})
|
||||
let settled = false
|
||||
|
||||
return Buffer.concat([header, entry.name])
|
||||
}
|
||||
/**
|
||||
* ZIP 스트림을 한 번만 종료한다.
|
||||
* @param {Error|null} error - 실패 오류
|
||||
* @returns {void}
|
||||
*/
|
||||
const finish = (error = null) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
settled = true
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
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
|
||||
output.on('close', () => finish())
|
||||
output.on('error', (error) => finish(error))
|
||||
archive.on('error', (error) => finish(error))
|
||||
archive.on('warning', (error) => {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
finish(error)
|
||||
}
|
||||
})
|
||||
archive.pipe(output)
|
||||
|
||||
for (const file of files) {
|
||||
const path = normalizeZipPath(file.path)
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (file.sourcePath) {
|
||||
archive.file(file.sourcePath, {
|
||||
name: path,
|
||||
store: file.store !== false
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const data = Buffer.isBuffer(file.data)
|
||||
? file.data
|
||||
: Buffer.from(String(file.data || ''), 'utf8')
|
||||
archive.append(data, {
|
||||
name: path,
|
||||
store: Boolean(file.store)
|
||||
})
|
||||
}
|
||||
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])
|
||||
archive.finalize().catch((error) => finish(error))
|
||||
}).catch(async (error) => {
|
||||
await rm(outputPath, {
|
||||
force: true
|
||||
})
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user