이미지 파생 파일 생성 안정화
This commit is contained in:
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user