import { basename, extname, posix } from 'node:path' import { z } from 'zod' const mediaUrlSchema = z.string().trim().min(1).max(2048) const imageExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']) const videoExtensions = new Set(['.mp4', '.webm', '.mov']) const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a']) /** * PostgreSQL LIKE 검색어의 wildcard 문자를 리터럴로 이스케이프한다. * SQL에서는 `ESCAPE '!'`와 함께 사용한다. * @param {string} value - 검색어 * @returns {string} LIKE 리터럴 */ export const escapePostgresLikeLiteral = (value) => String(value || '') .replace(/!/g, '!!') .replace(/%/g, '!%') .replace(/_/g, '!_') /** * 업로드 URL을 카탈로그의 공개 URL과 저장 키로 정규화한다. * @param {unknown} value - 입력 URL * @returns {{ url: string, storageKey: string }} 정규화 결과 */ export const normalizeMediaCatalogUrl = (value) => { const input = mediaUrlSchema.parse(value) const pathOnly = input.split(/[?#]/, 1)[0] let decodedPath = '' try { decodedPath = decodeURIComponent(pathOnly.replace(/%(?![0-9a-f]{2})/gi, '%25')) } catch { throw new Error('MEDIA_URL_INVALID_ENCODING') } if (decodedPath.includes('\0') || decodedPath.includes('\\')) { throw new Error('MEDIA_URL_INVALID_PATH') } const normalizedPath = posix.normalize(decodedPath) const rawSegments = decodedPath.split('/') const normalizedSegments = normalizedPath.split('/') if (rawSegments.some((segment) => segment === '.' || segment === '..') || normalizedSegments.some((segment) => segment === '..') || normalizedPath !== decodedPath.replace(/\/+/g, '/')) { throw new Error('MEDIA_URL_TRAVERSAL') } if (!normalizedPath.startsWith('/uploads/') || normalizedPath === '/uploads/') { throw new Error('MEDIA_URL_OUTSIDE_UPLOADS') } const storageKey = normalizedPath.slice('/uploads/'.length) return { url: `/uploads/${storageKey}`, storageKey } } /** * 확장자에서 미디어 종류를 판별한다. * @param {string} fileName - 파일명 * @returns {'image'|'video'|'audio'|'file'} 미디어 종류 */ export const detectMediaKind = (fileName) => { const extension = extname(String(fileName || '')).toLowerCase() if (imageExtensions.has(extension)) { return 'image' } if (videoExtensions.has(extension)) { return 'video' } if (audioExtensions.has(extension)) { return 'audio' } return 'file' } /** * 저장 키에서 미디어 역할과 원본 후보 키를 판별한다. * @param {string} storageKey - 업로드 루트 기준 저장 키 * @returns {{ role: string, parentStorageKey: string|null }} 역할 판별 결과 */ export const detectMediaRole = (storageKey) => { const key = String(storageKey || '').replace(/^\/+/, '') const fileName = basename(key) const directory = posix.dirname(key) const thumbsDirectory = directory.endsWith('/thumbs') const derivedMatch = fileName.match(/^(.*)-(card|page-cover|body(?:-(?:720|1600|2560))?|lightbox)\.webp$/i) if (key.startsWith('exports/')) { return { role: 'export', parentStorageKey: null } } if (key.includes('/members/avatars/') || key.startsWith('members/avatars/')) { return { role: 'avatar', parentStorageKey: null } } if (/^(system|site)\//.test(key) || key.startsWith('tags/')) { return { role: 'system', parentStorageKey: null } } if (thumbsDirectory && derivedMatch) { const originalDirectory = posix.dirname(directory) const parentCandidates = ['.jpg', '.jpeg', '.png', '.webp', '.gif'] const parentStorageKey = parentCandidates .map((extension) => posix.join(originalDirectory, `${derivedMatch[1]}${extension}`))[0] const variant = derivedMatch[2].toLowerCase() const role = variant === 'card' ? 'card' : variant === 'page-cover' ? 'page_cover' : 'body' return { role, parentStorageKey } } return { role: 'original', parentStorageKey: null } } /** * 문자열 필드에서 로컬 미디어 URL 참조를 중복 없이 추출한다. * @param {Record} fields - 필드명별 값 * @returns {Array<{ mediaUrl: string, referenceField: string }>} 참조 목록 */ export const extractMediaReferences = (fields) => { const references = new Map() const mediaPattern = /\/uploads\/[^\s"'<>()[\]{}]+/g for (const [referenceField, rawValue] of Object.entries(fields || {})) { if (typeof rawValue !== 'string' || !rawValue.includes('/uploads/')) { continue } for (const match of rawValue.matchAll(mediaPattern)) { const candidate = match[0].replace(/[.,;:!?]+$/g, '') try { const { url } = normalizeMediaCatalogUrl(candidate) references.set(`${referenceField}\0${url}`, { mediaUrl: url, referenceField }) } catch { continue } } } return [...references.values()] }