127 lines
3.6 KiB
TypeScript
127 lines
3.6 KiB
TypeScript
|
|
import fs from 'fs/promises'
|
||
|
|
import path from 'path'
|
||
|
|
import { InternalError, ValidationError } from '../../shared/errors/index.js'
|
||
|
|
|
||
|
|
export const getUniqueFilename = async (imagesDirFullPath: string, baseName: string, ext: string) => {
|
||
|
|
const maxAttempts = 1000
|
||
|
|
for (let i = 0; i < maxAttempts; i++) {
|
||
|
|
const suffix = i === 0 ? '' : `-${i + 1}`
|
||
|
|
const filename = `${baseName}${suffix}${ext}`
|
||
|
|
const fullPath = path.join(imagesDirFullPath, filename)
|
||
|
|
try {
|
||
|
|
await fs.access(fullPath)
|
||
|
|
} catch {
|
||
|
|
return filename
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw new InternalError('Failed to generate unique filename')
|
||
|
|
}
|
||
|
|
|
||
|
|
export const mimeToExt: Record<string, string> = {
|
||
|
|
'image/png': '.png',
|
||
|
|
'image/jpeg': '.jpg',
|
||
|
|
'image/jpg': '.jpg',
|
||
|
|
'image/gif': '.gif',
|
||
|
|
'image/webp': '.webp',
|
||
|
|
}
|
||
|
|
|
||
|
|
const IMAGE_MAGIC_BYTES: Record<string, { bytes: number[]; offset?: number }> = {
|
||
|
|
'image/png': { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
||
|
|
'image/jpeg': { bytes: [0xff, 0xd8, 0xff] },
|
||
|
|
'image/gif': { bytes: [0x47, 0x49, 0x46, 0x38] },
|
||
|
|
'image/webp': { bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 },
|
||
|
|
}
|
||
|
|
|
||
|
|
const WEBP_RIFF_HEADER = [0x52, 0x49, 0x46, 0x46]
|
||
|
|
const WEBP_WEBP_MARKER = [0x57, 0x45, 0x42, 0x50]
|
||
|
|
|
||
|
|
const MIN_IMAGE_SIZE = 16
|
||
|
|
const MAX_IMAGE_SIZE = 8 * 1024 * 1024
|
||
|
|
|
||
|
|
export const validateImageBuffer = (buffer: Buffer, claimedMimeType: string): void => {
|
||
|
|
if (buffer.byteLength < MIN_IMAGE_SIZE) {
|
||
|
|
throw new ValidationError('Image file is too small or corrupted')
|
||
|
|
}
|
||
|
|
|
||
|
|
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||
|
|
throw new ValidationError('Image file is too large')
|
||
|
|
}
|
||
|
|
|
||
|
|
const magicInfo = IMAGE_MAGIC_BYTES[claimedMimeType]
|
||
|
|
if (!magicInfo) {
|
||
|
|
throw new ValidationError('Unsupported image type for content validation')
|
||
|
|
}
|
||
|
|
|
||
|
|
const offset = magicInfo.offset || 0
|
||
|
|
const expectedBytes = magicInfo.bytes
|
||
|
|
|
||
|
|
for (let i = 0; i < expectedBytes.length; i++) {
|
||
|
|
if (buffer[offset + i] !== expectedBytes[i]) {
|
||
|
|
throw new ValidationError('Image content does not match the claimed file type')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (claimedMimeType === 'image/webp') {
|
||
|
|
if (buffer.byteLength < 12) {
|
||
|
|
throw new ValidationError('WebP image is corrupted')
|
||
|
|
}
|
||
|
|
for (let i = 0; i < WEBP_WEBP_MARKER.length; i++) {
|
||
|
|
if (buffer[8 + i] !== WEBP_WEBP_MARKER[i]) {
|
||
|
|
throw new ValidationError('WebP image content is invalid')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const detectImageMimeType = (buffer: Buffer): string | null => {
|
||
|
|
if (buffer.byteLength < 8) return null
|
||
|
|
|
||
|
|
if (
|
||
|
|
buffer[0] === 0x89 &&
|
||
|
|
buffer[1] === 0x50 &&
|
||
|
|
buffer[2] === 0x4e &&
|
||
|
|
buffer[3] === 0x47 &&
|
||
|
|
buffer[4] === 0x0d &&
|
||
|
|
buffer[5] === 0x0a &&
|
||
|
|
buffer[6] === 0x1a &&
|
||
|
|
buffer[7] === 0x0a
|
||
|
|
) {
|
||
|
|
return 'image/png'
|
||
|
|
}
|
||
|
|
|
||
|
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||
|
|
return 'image/jpeg'
|
||
|
|
}
|
||
|
|
|
||
|
|
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
|
||
|
|
return 'image/gif'
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
buffer[0] === 0x52 &&
|
||
|
|
buffer[1] === 0x49 &&
|
||
|
|
buffer[2] === 0x46 &&
|
||
|
|
buffer[3] === 0x46 &&
|
||
|
|
buffer[8] === 0x57 &&
|
||
|
|
buffer[9] === 0x45 &&
|
||
|
|
buffer[10] === 0x42 &&
|
||
|
|
buffer[11] === 0x50
|
||
|
|
) {
|
||
|
|
return 'image/webp'
|
||
|
|
}
|
||
|
|
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
export const sanitizeFilename = (filename: string): string => {
|
||
|
|
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||
|
|
sanitized = sanitized.replace(/^\.+|\.+$/g, '')
|
||
|
|
sanitized = sanitized.replace(/\.{2,}/g, '.')
|
||
|
|
if (sanitized.length > 200) {
|
||
|
|
const ext = path.extname(filename)
|
||
|
|
const baseName = path.basename(filename, ext)
|
||
|
|
sanitized = baseName.substring(0, 200 - ext.length) + ext
|
||
|
|
}
|
||
|
|
return sanitized || 'unnamed'
|
||
|
|
}
|