import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { extname, join, relative } from 'node:path' import { createError, getRequestHeader, getRequestURL, sendStream, setResponseHeader, setResponseStatus } from 'h3' const uploadRoot = join(process.cwd(), 'public', 'uploads') const contentTypes = new Map([ ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.png', 'image/png'], ['.webp', 'image/webp'], ['.gif', 'image/gif'], ['.svg', 'image/svg+xml'], ['.ico', 'image/x-icon'] ]) const regeneratableUploadPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-(?:card|page-cover|body|body-720|body-1600|body-2560|lightbox)\.webp$/i /** * 업로드 파일 캐시 정책을 반환한다. * @param {string} pathname - 요청 경로 * @returns {string} Cache-Control 값 */ const getUploadCacheControl = (pathname) => { if (regeneratableUploadPattern.test(pathname)) { return 'public, no-cache' } return 'public, max-age=31536000, immutable' } /** * 파일 통계 기반 약한 ETag 값을 만든다. * @param {import('node:fs').Stats} fileStat - 파일 통계 * @returns {string} ETag 값 */ const createUploadEtag = (fileStat) => `W/"${fileStat.size}-${Math.trunc(fileStat.mtimeMs)}"` /** * 조건부 요청이 현재 파일과 일치하는지 확인한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {string} etag - 현재 ETag * @param {Date} modifiedAt - 마지막 수정 시각 * @returns {boolean} 변경 없음 여부 */ const isUploadNotModified = (event, etag, modifiedAt) => { const ifNoneMatch = String(getRequestHeader(event, 'if-none-match') || '') if (ifNoneMatch.split(',').map((value) => value.trim()).includes(etag)) { return true } const ifModifiedSince = String(getRequestHeader(event, 'if-modified-since') || '') const modifiedSinceTime = Date.parse(ifModifiedSince) return Number.isFinite(modifiedSinceTime) && modifiedAt.getTime() <= modifiedSinceTime } /** * 업로드 요청 URL을 디스크 파일 경로로 변환한다. * @param {string} pathname - 요청 경로 * @returns {string} 디스크 파일 경로 */ const resolveUploadFilePath = (pathname) => { let decodedPath = '' try { decodedPath = decodeURIComponent(pathname) } catch { throw createError({ statusCode: 400, message: '업로드 파일 경로가 올바르지 않습니다.' }) } const relativeUrlPath = decodedPath.replace(/^\/uploads\/?/g, '') if (!relativeUrlPath || relativeUrlPath.includes('\0')) { throw createError({ statusCode: 404, message: '업로드 파일을 찾을 수 없습니다.' }) } const filePath = join(uploadRoot, relativeUrlPath) const relativeDiskPath = relative(uploadRoot, filePath) if (relativeDiskPath.startsWith('..') || relativeDiskPath === '') { throw createError({ statusCode: 400, message: '업로드 파일 경로가 올바르지 않습니다.' }) } return filePath } /** * 런타임 업로드 파일 제공 API * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise} 업로드 파일 스트림 */ export default defineEventHandler(async (event) => { const pathname = getRequestURL(event).pathname const filePath = resolveUploadFilePath(pathname) const fileStat = await stat(filePath).catch(() => null) if (!fileStat?.isFile()) { throw createError({ statusCode: 404, message: '업로드 파일을 찾을 수 없습니다.' }) } const extension = extname(filePath).toLowerCase() const contentType = contentTypes.get(extension) if (contentType) { setResponseHeader(event, 'content-type', contentType) } const etag = createUploadEtag(fileStat) setResponseHeader(event, 'cache-control', getUploadCacheControl(pathname)) setResponseHeader(event, 'last-modified', fileStat.mtime.toUTCString()) setResponseHeader(event, 'etag', etag) if (isUploadNotModified(event, etag, fileStat.mtime)) { setResponseStatus(event, 304) return null } setResponseHeader(event, 'content-length', String(fileStat.size)) return sendStream(event, createReadStream(filePath)) })