151 lines
4.4 KiB
JavaScript
151 lines
4.4 KiB
JavaScript
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'
|
|
import { getUploadRoot } from '../../utils/upload-root'
|
|
|
|
const uploadRoot = getUploadRoot()
|
|
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: '업로드 파일 경로가 올바르지 않습니다.'
|
|
})
|
|
}
|
|
|
|
if (relativeDiskPath === '_import-jobs' || relativeDiskPath.startsWith('_import-jobs/')) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
message: '업로드 파일을 찾을 수 없습니다.'
|
|
})
|
|
}
|
|
|
|
return filePath
|
|
}
|
|
|
|
/**
|
|
* 런타임 업로드 파일 제공 API
|
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
|
* @returns {Promise<void>} 업로드 파일 스트림
|
|
*/
|
|
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))
|
|
})
|