v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
151
lib/media-catalog.js
Normal file
151
lib/media-catalog.js
Normal file
@@ -0,0 +1,151 @@
|
||||
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<string, unknown>} 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()]
|
||||
}
|
||||
Reference in New Issue
Block a user