Files
XCDesktop/api/utils/file.ts
2026-03-08 01:34:54 +08:00

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'
}