Files
sori.studio/server/utils/post-thumbnail-image.js

517 lines
16 KiB
JavaScript

import { existsSync } from 'node:fs'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { dirname, extname, join, posix } from 'node:path'
import sharp from 'sharp'
import {
getPageCoverThumbnailUrl,
getLegacyPostBodyThumbnailUrl,
getLegacyPostLightboxThumbnailUrl,
getPostBodyPreviewThumbnailUrl,
getPostBodyThumbnailUrl,
getPostLightboxThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
export {
getPageCoverThumbnailUrl,
getLegacyPostBodyThumbnailUrl,
getLegacyPostLightboxThumbnailUrl,
getPostBodyPreviewThumbnailUrl,
getPostBodyThumbnailUrl,
getPostLightboxThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
}
export const POST_THUMBNAIL_WIDTH = 640
export const POST_THUMBNAIL_HEIGHT = 360
export const POST_THUMBNAIL_QUALITY = 82
export const POST_BODY_IMAGE_WIDTH = 1600
export const POST_BODY_PREVIEW_IMAGE_WIDTH = 720
export const POST_LIGHTBOX_IMAGE_WIDTH = 2560
export const POST_BODY_IMAGE_QUALITY = 84
export const PAGE_COVER_THUMBNAIL_WIDTH = 450
export const PAGE_COVER_THUMBNAIL_HEIGHT = 600
export const PAGE_COVER_THUMBNAIL_QUALITY = 84
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+$/i
const postUploadImageUrlPattern = /\/uploads\/posts\/\d{4}\/\d{2}\/[^/\s"'()<>\]]+\.(?:jpe?g|png|webp)/gi
const postThumbnailDirectoryName = 'thumbs'
const postThumbnailSuffix = '-card'
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 경로 조각
* @returns {string} 디코딩된 값
*/
const decodeUrlPathPart = (value) => {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
/**
* 게시물 이미지에서 카드 썸네일을 생성할 수 있는지 확인한다.
* @param {string} mimeType - 파일 MIME 타입
* @param {string} fileName - 파일명
* @returns {boolean} 썸네일 생성 가능 여부
*/
export const isPostThumbnailSource = (mimeType, fileName = '') => {
if (supportedPostThumbnailTypes.has(mimeType)) {
return true
}
return supportedPostThumbnailExtensions.has(extname(fileName).toLowerCase())
}
/**
* 원본 파일명에서 게시물 카드 썸네일 파일명을 만든다.
* @param {string} fileName - 원본 파일명
* @returns {string} 썸네일 파일명
*/
export const getPostThumbnailFileName = (fileName) => {
const extension = extname(fileName)
const stem = extension ? fileName.slice(0, -extension.length) : fileName
return `${stem}${postThumbnailSuffix}${postThumbnailExtension}`
}
/**
* 게시물 원본 이미지 URL에 대응하는 카드 썸네일 URL을 만든다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 카드 썸네일 URL
*/
export const getPostThumbnailUrl = (imageUrl) => {
if (!imageUrl || !postUploadUrlPattern.test(imageUrl)) {
return ''
}
const directory = posix.dirname(imageUrl)
const fileName = posix.basename(imageUrl)
const extension = extname(fileName).toLowerCase()
if (!supportedPostThumbnailExtensions.has(extension)) {
return ''
}
return `${directory}/${postThumbnailDirectoryName}/${getPostThumbnailFileName(fileName)}`
}
/**
* 게시물 카드 썸네일 URL의 디스크 경로를 조회한다.
* @param {string} imageUrl - 원본 이미지 URL
* @returns {string} 썸네일 디스크 경로
*/
export const getPostThumbnailDiskPath = (imageUrl) => {
const thumbnailUrl = getPostThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 본문 표시용 이미지 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 본문 표시용 이미지 디스크 경로
*/
export const getPostBodyThumbnailDiskPath = (imageUrl) => {
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 작은 본문 표시용 이미지 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 작은 본문 표시용 이미지 디스크 경로
*/
export const getPostBodyPreviewThumbnailDiskPath = (imageUrl) => {
const thumbnailUrl = getPostBodyPreviewThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 라이트박스용 이미지 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 라이트박스용 이미지 디스크 경로
*/
export const getPostLightboxThumbnailDiskPath = (imageUrl) => {
const thumbnailUrl = getPostLightboxThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 페이지 커버 썸네일 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 페이지 커버 썸네일 디스크 경로
*/
export const getPageCoverThumbnailDiskPath = (imageUrl) => {
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 원본 이미지 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 원본 이미지 디스크 경로
*/
export const getPostImageDiskPath = (imageUrl) => {
if (!imageUrl || !postUploadUrlPattern.test(imageUrl)) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(imageUrl.replace(/^\/+/, '')))
}
/**
* 게시물 원본 이미지 파일의 썸네일 저장 디렉터리 경로를 조회한다.
* @param {string} imageFilePath - 원본 이미지 디스크 경로
* @returns {string} 썸네일 저장 디렉터리 경로
*/
export const getPostThumbnailDirectoryPath = (imageFilePath) => join(dirname(imageFilePath), postThumbnailDirectoryName)
/**
* 게시물 원본 이미지 파일의 썸네일 저장 경로를 조회한다.
* @param {string} imageFilePath - 원본 이미지 디스크 경로
* @returns {string} 썸네일 저장 경로
*/
export const getPostThumbnailPathForFile = (imageFilePath) => {
const fileName = imageFilePath.split(/[\\/]/).pop() || ''
return join(getPostThumbnailDirectoryPath(imageFilePath), getPostThumbnailFileName(fileName))
}
/**
* 이미 생성된 게시물 카드 썸네일 URL을 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 존재하는 썸네일 URL
*/
export const getExistingPostThumbnailUrl = (imageUrl) => {
const thumbnailUrl = getPostThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return existsSync(getPostThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 이미 생성된 게시물 본문 표시용 이미지 URL을 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 존재하는 본문 표시용 이미지 URL
*/
export const getExistingPostBodyThumbnailUrl = (imageUrl) => {
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return existsSync(getPostBodyThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 이미 생성된 작은 게시물 본문 표시용 이미지 URL을 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 존재하는 작은 본문 표시용 이미지 URL
*/
export const getExistingPostBodyPreviewThumbnailUrl = (imageUrl) => {
const thumbnailUrl = getPostBodyPreviewThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return existsSync(getPostBodyPreviewThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 이미 생성된 게시물 라이트박스용 이미지 URL을 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 존재하는 라이트박스용 이미지 URL
*/
export const getExistingPostLightboxThumbnailUrl = (imageUrl) => {
const thumbnailUrl = getPostLightboxThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return existsSync(getPostLightboxThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 이미 생성된 페이지 커버 썸네일 URL을 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 존재하는 페이지 커버 썸네일 URL
*/
export const getExistingPageCoverThumbnailUrl = (imageUrl) => {
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return existsSync(getPageCoverThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {Promise<string>} 생성된 카드 썸네일 URL
*/
export const createPostThumbnailForImageUrl = async (imageUrl) => {
const sourcePath = getPostImageDiskPath(imageUrl)
const thumbnailUrl = getPostThumbnailUrl(imageUrl)
const thumbnailPath = getPostThumbnailDiskPath(imageUrl)
if (!sourcePath || !thumbnailUrl || !thumbnailPath) {
return ''
}
try {
await stat(sourcePath)
} catch {
return ''
}
const created = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_THUMBNAIL_WIDTH,
height: POST_THUMBNAIL_HEIGHT,
fit: 'cover',
position: 'centre',
withoutEnlargement: true
})
.webp({ quality: POST_THUMBNAIL_QUALITY })
.toFile(temporaryPath))
return created ? thumbnailUrl : ''
}
/**
* 페이지 홈 진열장용 3:4 커버 썸네일을 생성하거나 다시 생성한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {Promise<string>} 생성된 페이지 커버 썸네일 URL
*/
export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
const sourcePath = getPostImageDiskPath(imageUrl)
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
const thumbnailPath = getPageCoverThumbnailDiskPath(imageUrl)
if (!sourcePath || !thumbnailUrl || !thumbnailPath) {
return ''
}
try {
await stat(sourcePath)
} catch {
return ''
}
const created = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: PAGE_COVER_THUMBNAIL_WIDTH,
height: PAGE_COVER_THUMBNAIL_HEIGHT,
fit: 'cover',
position: 'centre',
withoutEnlargement: true
})
.webp({ quality: PAGE_COVER_THUMBNAIL_QUALITY })
.toFile(temporaryPath))
return created ? thumbnailUrl : ''
}
/**
* 게시물 본문 이미지의 표시용 WebP 이미지를 생성하거나 다시 생성한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {Promise<string>} 생성된 본문 표시용 이미지 URL
*/
export const createPostBodyThumbnailForImageUrl = async (imageUrl) => {
const sourcePath = getPostImageDiskPath(imageUrl)
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
const thumbnailPath = getPostBodyThumbnailDiskPath(imageUrl)
const previewThumbnailPath = getPostBodyPreviewThumbnailDiskPath(imageUrl)
const lightboxThumbnailPath = getPostLightboxThumbnailDiskPath(imageUrl)
if (!sourcePath || !thumbnailUrl || !thumbnailPath || !previewThumbnailPath || !lightboxThumbnailPath) {
return ''
}
try {
await stat(sourcePath)
} catch {
return ''
}
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))
if (!createdBody || !createdPreview || !createdLightbox) {
await Promise.all([
removeImageFileIfExists(thumbnailPath),
removeImageFileIfExists(previewThumbnailPath),
removeImageFileIfExists(lightboxThumbnailPath)
])
return ''
}
return thumbnailUrl
}
/**
* 게시물 본문에서 업로드 이미지 URL을 추출한다.
* @param {string|null|undefined} content - 게시물 본문 마크다운
* @returns {Array<string>} 중복 제거된 이미지 URL 목록
*/
export const extractPostImageUrlsFromContent = (content) => {
const matches = String(content || '').match(postUploadImageUrlPattern) || []
return [...new Set(matches)]
}
/**
* 게시물 본문에 포함된 이미지의 표시용 WebP 이미지를 생성한다.
* @param {string|null|undefined} content - 게시물 본문 마크다운
* @returns {Promise<Array<string>>} 생성된 본문 표시용 이미지 URL 목록
*/
export const createPostBodyThumbnailsForContent = async (content) => {
const imageUrls = extractPostImageUrlsFromContent(content)
const generatedUrls = []
for (const imageUrl of imageUrls) {
const thumbnailUrl = await createPostBodyThumbnailForImageUrl(imageUrl)
if (thumbnailUrl) {
generatedUrls.push(thumbnailUrl)
}
}
return generatedUrls
}