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' import { recordMediaCatalogFile } from '../repositories/media-catalog-repository.js' import { resolveUploadUrlPath } from './upload-root.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} 제거 결과 */ const removeImageFileIfExists = async (filePath) => { if (!filePath) { return } await rm(filePath, { force: true }).catch(() => {}) } /** * 생성된 이미지 파일이 실제로 읽히는지 확인한다. * @param {string} filePath - 검사할 이미지 파일 경로 * @returns {Promise} 유효한 이미지이면 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} writeTemporaryImage - 임시 이미지 생성 함수 * @returns {Promise} 생성 성공 여부 */ 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 resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl)) } /** * 게시물 본문 표시용 이미지 URL의 디스크 경로를 조회한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL * @returns {string} 본문 표시용 이미지 디스크 경로 */ export const getPostBodyThumbnailDiskPath = (imageUrl) => { const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl) if (!thumbnailUrl) { return '' } return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl)) } /** * 게시물 작은 본문 표시용 이미지 URL의 디스크 경로를 조회한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL * @returns {string} 작은 본문 표시용 이미지 디스크 경로 */ export const getPostBodyPreviewThumbnailDiskPath = (imageUrl) => { const thumbnailUrl = getPostBodyPreviewThumbnailUrl(imageUrl) if (!thumbnailUrl) { return '' } return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl)) } /** * 게시물 라이트박스용 이미지 URL의 디스크 경로를 조회한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL * @returns {string} 라이트박스용 이미지 디스크 경로 */ export const getPostLightboxThumbnailDiskPath = (imageUrl) => { const thumbnailUrl = getPostLightboxThumbnailUrl(imageUrl) if (!thumbnailUrl) { return '' } return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl)) } /** * 페이지 커버 썸네일 URL의 디스크 경로를 조회한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL * @returns {string} 페이지 커버 썸네일 디스크 경로 */ export const getPageCoverThumbnailDiskPath = (imageUrl) => { const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl) if (!thumbnailUrl) { return '' } return resolveUploadUrlPath(decodeUrlPathPart(thumbnailUrl)) } /** * 게시물 원본 이미지 URL의 디스크 경로를 조회한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL * @returns {string} 원본 이미지 디스크 경로 */ export const getPostImageDiskPath = (imageUrl) => { if (!imageUrl || !postUploadUrlPattern.test(imageUrl)) { return '' } return resolveUploadUrlPath(decodeUrlPathPart(imageUrl)) } /** * 게시물 원본 이미지 파일의 썸네일 저장 디렉터리 경로를 조회한다. * @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} 생성된 카드 썸네일 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)) if (created) { await recordMediaCatalogFile(thumbnailUrl, { origin: 'derived', role: 'card' }) } return created ? thumbnailUrl : '' } /** * 페이지 홈 진열장용 3:4 커버 썸네일을 생성하거나 다시 생성한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL * @returns {Promise} 생성된 페이지 커버 썸네일 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)) if (created) { await recordMediaCatalogFile(thumbnailUrl, { origin: 'derived', role: 'page_cover' }) } return created ? thumbnailUrl : '' } /** * 게시물 본문 이미지의 표시용 WebP 이미지를 생성하거나 다시 생성한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL * @returns {Promise} 생성된 본문 표시용 이미지 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 '' } 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 } /** * 게시물 본문에서 업로드 이미지 URL을 추출한다. * @param {string|null|undefined} content - 게시물 본문 마크다운 * @returns {Array} 중복 제거된 이미지 URL 목록 */ export const extractPostImageUrlsFromContent = (content) => { const matches = String(content || '').match(postUploadImageUrlPattern) || [] return [...new Set(matches)] } /** * 게시물 본문에 포함된 이미지의 표시용 WebP 이미지를 생성한다. * @param {string|null|undefined} content - 게시물 본문 마크다운 * @returns {Promise>} 생성된 본문 표시용 이미지 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 }