게시물 목록 카드 썸네일 생성 추가
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
parseSignupBlockedUsernamesFromDb
|
||||
} from '../../lib/signup-blocked-usernames.js'
|
||||
import { normalizeSocialLinks } from '../../lib/social-links.js'
|
||||
import { getExistingPostThumbnailUrl } from '../utils/post-thumbnail-image.js'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,7 @@ const mapPostRow = (row) => ({
|
||||
content: row.content,
|
||||
excerpt: row.excerpt,
|
||||
featuredImage: row.featured_image,
|
||||
featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image),
|
||||
isFeatured: Boolean(row.is_featured),
|
||||
commentCount: Number(row.comment_count || 0),
|
||||
seoTitle: row.seo_title || '',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { extname, join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import {
|
||||
buildDefaultUploadSizeLimits,
|
||||
formatUploadSizeLimit,
|
||||
@@ -11,6 +12,13 @@ import {
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
|
||||
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
|
||||
import {
|
||||
POST_THUMBNAIL_HEIGHT,
|
||||
POST_THUMBNAIL_QUALITY,
|
||||
POST_THUMBNAIL_WIDTH,
|
||||
getPostThumbnailFileName,
|
||||
isPostThumbnailSource
|
||||
} from '../../../utils/post-thumbnail-image.js'
|
||||
|
||||
const allowedUploadTypes = new Map([
|
||||
['image/jpeg', '.jpg'],
|
||||
@@ -118,6 +126,38 @@ const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 목록용 카드 썸네일을 생성한다.
|
||||
* @param {Object} options - 생성 옵션
|
||||
* @param {Buffer} options.sourceBuffer - 원본 이미지 버퍼
|
||||
* @param {string} options.directoryPath - 원본 이미지 저장 디렉터리
|
||||
* @param {string} options.fileName - 원본 파일명
|
||||
* @returns {Promise<string>} 생성된 썸네일 파일명
|
||||
*/
|
||||
const createPostCardThumbnail = async ({ sourceBuffer, directoryPath, fileName }) => {
|
||||
const thumbnailDirectoryPath = join(directoryPath, 'thumbs')
|
||||
const thumbnailFileName = getPostThumbnailFileName(fileName)
|
||||
const thumbnailPath = join(thumbnailDirectoryPath, thumbnailFileName)
|
||||
|
||||
await mkdir(thumbnailDirectoryPath, { recursive: true })
|
||||
|
||||
const thumbnailBuffer = await sharp(sourceBuffer)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: POST_THUMBNAIL_WIDTH,
|
||||
height: POST_THUMBNAIL_HEIGHT,
|
||||
fit: 'cover',
|
||||
position: 'centre',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.webp({ quality: POST_THUMBNAIL_QUALITY })
|
||||
.toBuffer()
|
||||
|
||||
await writeFile(thumbnailPath, thumbnailBuffer)
|
||||
|
||||
return thumbnailFileName
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 미디어 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -179,11 +219,22 @@ export default defineEventHandler(async (event) => {
|
||||
await writeFile(filePath, file.data)
|
||||
|
||||
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
|
||||
let thumbnailUrl = ''
|
||||
|
||||
if (isPostThumbnailSource(file.type, fileName)) {
|
||||
const thumbnailFileName = await createPostCardThumbnail({
|
||||
sourceBuffer: file.data,
|
||||
directoryPath,
|
||||
fileName
|
||||
})
|
||||
thumbnailUrl = `${uploadBaseUrl}/posts/${year}/${month}/thumbs/${thumbnailFileName}`
|
||||
}
|
||||
|
||||
await upsertMediaMetadataCategory(publicUrl, '미분류')
|
||||
|
||||
uploadedFiles.push({
|
||||
url: publicUrl,
|
||||
thumbnailUrl,
|
||||
name: fileName,
|
||||
size: file.data.length
|
||||
})
|
||||
|
||||
121
server/utils/post-thumbnail-image.js
Normal file
121
server/utils/post-thumbnail-image.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, extname, join, posix } from 'node:path'
|
||||
|
||||
export const POST_THUMBNAIL_WIDTH = 640
|
||||
export const POST_THUMBNAIL_HEIGHT = 360
|
||||
export const POST_THUMBNAIL_QUALITY = 82
|
||||
|
||||
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^?#]+$/i
|
||||
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'])
|
||||
|
||||
/**
|
||||
* 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(/^\/+/, '')))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 원본 이미지 파일의 썸네일 저장 디렉터리 경로를 조회한다.
|
||||
* @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 : ''
|
||||
}
|
||||
Reference in New Issue
Block a user