이미지 파생 파일 생성 안정화

This commit is contained in:
2026-07-02 10:27:37 +09:00
parent 3a4e39332a
commit ffd6cbb822
12 changed files with 190 additions and 62 deletions

View File

@@ -1,14 +1,32 @@
import { z } from 'zod'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { pageStatusSchema } from './content-schema.js'
/**
* 자동 생성 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} 자동 생성 이미지이면 true
*/
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|| isPostBodyThumbnailUrl(imageUrl)
|| isPageCoverThumbnailUrl(imageUrl)
export const adminPageInputSchema = z.object({
title: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:[-_][a-z0-9가-힣]+)*$/),
renderMode: z.enum(['markdown', 'html_document']).default('html_document'),
status: pageStatusSchema.default('published'),
content: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
featuredImage: z.string()
.trim()
.nullable()
.default(null)
.refine((value) => !isGeneratedThumbnailUrl(value), '자동 생성 이미지는 페이지 커버로 사용할 수 없습니다.'),
isListed: z.boolean().optional().default(false),
isFeatured: z.boolean().optional().default(false),
listingSection: z.string().trim().max(80).optional().default(''),

View File

@@ -1,9 +1,22 @@
import { z } from 'zod'
import { ADMIN_POST_PLACEHOLDER_TITLE } from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { postStatusSchema } from './content-schema.js'
/**
* 자동 생성 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} 자동 생성 이미지이면 true
*/
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|| isPostBodyThumbnailUrl(imageUrl)
|| isPageCoverThumbnailUrl(imageUrl)
export const adminPostInputSchema = z.object({
title: z.preprocess((val) => {
const t = String(val ?? '').trim()
@@ -16,7 +29,7 @@ export const adminPostInputSchema = z.object({
.trim()
.nullable()
.default(null)
.refine((value) => !isPostCardThumbnailUrl(value), '카드 썸네일은 대표 이미지로 사용할 수 없습니다.'),
.refine((value) => !isGeneratedThumbnailUrl(value), '자동 생성 이미지는 대표 이미지로 사용할 수 없습니다.'),
showFeaturedImage: z.boolean().default(false),
isFeatured: z.boolean().default(false),
seoTitle: z.string().trim().default(''),

View File

@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import { mkdir, stat } from 'node:fs/promises'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { dirname, extname, join, posix } from 'node:path'
import sharp from 'sharp'
import {
@@ -41,6 +41,72 @@ const postThumbnailExtension = '.webp'
const supportedPostThumbnailExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp'])
const supportedPostThumbnailTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
/**
* 임시 이미지 출력 경로를 만든다.
* @param {string} filePath - 최종 저장 경로
* @returns {string} 임시 저장 경로
*/
const createTemporaryImagePath = (filePath) => `${filePath}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
/**
* 파일이 있으면 조용히 제거한다.
* @param {string} filePath - 제거할 파일 경로
* @returns {Promise<void>} 제거 결과
*/
const removeImageFileIfExists = async (filePath) => {
if (!filePath) {
return
}
await rm(filePath, { force: true }).catch(() => {})
}
/**
* 생성된 이미지 파일이 실제로 읽히는지 확인한다.
* @param {string} filePath - 검사할 이미지 파일 경로
* @returns {Promise<boolean>} 유효한 이미지이면 true
*/
const isGeneratedImageReadable = async (filePath) => {
const fileStat = await stat(filePath).catch(() => null)
if (!fileStat?.isFile() || fileStat.size <= 0) {
return false
}
try {
await sharp(filePath).metadata()
return true
} catch {
return false
}
}
/**
* 임시 파일로 이미지를 만든 뒤 검증이 끝나면 최종 파일로 교체한다.
* @param {string} targetPath - 최종 저장 경로
* @param {(temporaryPath: string) => Promise<void>} writeTemporaryImage - 임시 이미지 생성 함수
* @returns {Promise<boolean>} 생성 성공 여부
*/
const writeVerifiedDerivativeImage = async (targetPath, writeTemporaryImage) => {
const temporaryPath = createTemporaryImagePath(targetPath)
try {
await mkdir(dirname(targetPath), { recursive: true })
await writeTemporaryImage(temporaryPath)
if (!await isGeneratedImageReadable(temporaryPath)) {
throw new Error('INVALID_DERIVATIVE_IMAGE')
}
await rename(temporaryPath, targetPath)
return true
} catch {
await removeImageFileIfExists(temporaryPath)
await removeImageFileIfExists(targetPath)
return false
}
}
/**
* URL 경로 조각을 파일 시스템 경로용 문자열로 디코딩한다.
* @param {string} value - URL 경로 조각
@@ -302,9 +368,7 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
return ''
}
await mkdir(dirname(thumbnailPath), { recursive: true })
await sharp(sourcePath)
const created = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_THUMBNAIL_WIDTH,
@@ -314,9 +378,9 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
withoutEnlargement: true
})
.webp({ quality: POST_THUMBNAIL_QUALITY })
.toFile(thumbnailPath)
.toFile(temporaryPath))
return thumbnailUrl
return created ? thumbnailUrl : ''
}
/**
@@ -339,9 +403,7 @@ export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
return ''
}
await mkdir(dirname(thumbnailPath), { recursive: true })
await sharp(sourcePath)
const created = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: PAGE_COVER_THUMBNAIL_WIDTH,
@@ -351,9 +413,9 @@ export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
withoutEnlargement: true
})
.webp({ quality: PAGE_COVER_THUMBNAIL_QUALITY })
.toFile(thumbnailPath)
.toFile(temporaryPath))
return thumbnailUrl
return created ? thumbnailUrl : ''
}
/**
@@ -378,39 +440,40 @@ export const createPostBodyThumbnailForImageUrl = async (imageUrl) => {
return ''
}
try {
await mkdir(dirname(thumbnailPath), { recursive: true })
const createdBody = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(temporaryPath))
const createdPreview = await writeVerifiedDerivativeImage(previewThumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_PREVIEW_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(temporaryPath))
const createdLightbox = await writeVerifiedDerivativeImage(lightboxThumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_LIGHTBOX_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(temporaryPath))
await sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(thumbnailPath)
await sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_PREVIEW_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(previewThumbnailPath)
await sharp(sourcePath)
.rotate()
.resize({
width: POST_LIGHTBOX_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(lightboxThumbnailPath)
} catch {
if (!createdBody || !createdPreview || !createdLightbox) {
await Promise.all([
removeImageFileIfExists(thumbnailPath),
removeImageFileIfExists(previewThumbnailPath),
removeImageFileIfExists(lightboxThumbnailPath)
])
return ''
}