Files
sori.studio/server/utils/backup-storage-summary.js
zenn 2e5a973d11 관리자 설정 사이트 용량 카드를 간결한 통계형 UI로 정리
백업 현황을 사이트 용량으로 재정의하고 파일 집계를 이미지·영상·오디오·첨부 파일·보내기로 명확히 분리해 합계 불일치를 해소했다. 설정 화면의 통계 카드 스타일을 통일해 가독성을 높였다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:29:03 +09:00

146 lines
3.6 KiB
JavaScript

import { readdir, stat } from 'node:fs/promises'
import { extname, join, relative, sep } from 'node:path'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'])
const videoExtensions = new Set(['.mp4', '.mov', '.webm'])
const audioExtensions = new Set(['.m4a', '.mp3', '.ogg', '.wav'])
/**
* 빈 파일 분류 집계를 만든다.
* @returns {{ count: number, sizeBytes: number }} 파일 집계
*/
const createFileBucket = () => ({
count: 0,
sizeBytes: 0
})
/**
* 파일 집계를 증가시킨다.
* @param {{ count: number, sizeBytes: number }} bucket - 집계 대상
* @param {number} sizeBytes - 파일 크기
* @returns {void}
*/
const addFileBucket = (bucket, sizeBytes) => {
bucket.count += 1
bucket.sizeBytes += sizeBytes
}
/**
* 확장자 기준 파일 종류를 판단한다.
* @param {string} filename - 파일명
* @returns {'images'|'videos'|'audio'|'files'} 파일 종류
*/
const getFileKind = (filename) => {
const extension = extname(filename).toLowerCase()
if (imageExtensions.has(extension)) {
return 'images'
}
if (videoExtensions.has(extension)) {
return 'videos'
}
if (audioExtensions.has(extension)) {
return 'audio'
}
return 'files'
}
/**
* 업로드 파일을 분류 집계에 반영한다.
* @param {Object} summary - 업로드 집계
* @param {string} relativePath - 업로드 루트 기준 상대 경로
* @param {number} sizeBytes - 파일 크기
* @returns {void}
*/
const addUploadFile = (summary, relativePath, sizeBytes) => {
const segments = relativePath.split(sep)
const firstSegment = segments[0] || ''
const kind = getFileKind(relativePath)
if (firstSegment === 'exports') {
addFileBucket(summary.exports, sizeBytes)
return
}
addFileBucket(summary.total, sizeBytes)
addFileBucket(summary.byKind[kind], sizeBytes)
if (segments.includes('thumbs')) {
addFileBucket(summary.derivatives, sizeBytes)
} else if (firstSegment === 'members') {
addFileBucket(summary.profiles, sizeBytes)
} else {
addFileBucket(summary.originals, sizeBytes)
}
}
/**
* 업로드 디렉터리를 재귀적으로 스캔한다.
* @param {string} directory - 현재 디렉터리
* @param {Object} summary - 업로드 집계
* @returns {Promise<void>}
*/
const scanUploadDirectory = async (directory, summary) => {
let entries = []
try {
entries = await readdir(directory, { withFileTypes: true })
} catch (error) {
if (error?.code === 'ENOENT') {
return
}
throw error
}
for (const entry of entries) {
const absolutePath = join(directory, entry.name)
if (entry.isDirectory()) {
await scanUploadDirectory(absolutePath, summary)
continue
}
if (!entry.isFile()) {
continue
}
const fileStat = await stat(absolutePath)
const relativePath = relative(uploadRoot, absolutePath)
if (!relativePath || relativePath.startsWith('..')) {
continue
}
addUploadFile(summary, relativePath, fileStat.size)
}
}
/**
* 사이트 용량 점검용 업로드 파일 집계를 조회한다.
* @returns {Promise<Object>} 업로드 파일 집계
*/
export const getBackupStorageSummary = async () => {
const summary = {
total: createFileBucket(),
originals: createFileBucket(),
derivatives: createFileBucket(),
profiles: createFileBucket(),
exports: createFileBucket(),
byKind: {
images: createFileBucket(),
videos: createFileBucket(),
audio: createFileBucket(),
files: createFileBucket()
}
}
await scanUploadDirectory(uploadRoot, summary)
return summary
}