fix architecture and write new tests
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import path from 'path'
|
||||
import multer from 'multer'
|
||||
import { ValidationError } from '../errors'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
|
||||
['image/jpeg', ['.jpg', '.jpeg']],
|
||||
['image/png', ['.png']],
|
||||
['image/webp', ['.webp']],
|
||||
['image/gif', ['.gif']],
|
||||
])
|
||||
|
||||
/**
|
||||
* Shared multer instance used by all upload endpoints.
|
||||
@@ -9,26 +16,68 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
*/
|
||||
export const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
limits: { fileSize: MAX_FILE_SIZE, files: 20 },
|
||||
})
|
||||
|
||||
type DetectedFile = { mime: string; ext: string }
|
||||
|
||||
export function detectImageType(buffer: Buffer): DetectedFile | null {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return { mime: 'image/jpeg', ext: '.jpg' }
|
||||
}
|
||||
|
||||
if (
|
||||
buffer.length >= 8 &&
|
||||
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 { mime: 'image/png', ext: '.png' }
|
||||
}
|
||||
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
|
||||
return { mime: 'image/webp', ext: '.webp' }
|
||||
}
|
||||
|
||||
if (buffer.length >= 6) {
|
||||
const sig = buffer.subarray(0, 6).toString('ascii')
|
||||
if (sig === 'GIF87a' || sig === 'GIF89a') return { mime: 'image/gif', ext: '.gif' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function assertSafeImageContent(file: Express.Multer.File) {
|
||||
const detected = detectImageType(file.buffer)
|
||||
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
|
||||
throw new ValidationError('Unsupported or spoofed image file')
|
||||
}
|
||||
|
||||
if (file.mimetype !== detected.mime) {
|
||||
throw new ValidationError(`MIME type does not match file content for "${file.originalname}"`)
|
||||
}
|
||||
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
const allowedExtensions = ALLOWED_IMAGE_TYPES.get(detected.mime) ?? []
|
||||
if (ext && !allowedExtensions.includes(ext)) {
|
||||
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a file was provided and is an image MIME type.
|
||||
* Call inside the route handler after the multer middleware runs.
|
||||
* Asserts that a file was provided and is an image by content, not by client claims.
|
||||
*/
|
||||
export function assertImageFile(
|
||||
file: Express.Multer.File | undefined,
|
||||
fieldLabel = 'file',
|
||||
): asserts file is Express.Multer.File {
|
||||
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
|
||||
if (!file.mimetype.startsWith('image/')) throw new ValidationError('Only image uploads are supported')
|
||||
assertSafeImageContent(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that at least one file was provided and all files are images.
|
||||
* Asserts that at least one file was provided and all files are image content.
|
||||
*/
|
||||
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
|
||||
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
|
||||
const nonImage = files.find((f) => !f.mimetype.startsWith('image/'))
|
||||
if (nonImage) throw new ValidationError(`All uploaded files must be images — "${nonImage.originalname}" is not`)
|
||||
for (const file of files) assertSafeImageContent(file)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ValidationError } from '../errors'
|
||||
import { assertImageFile, assertImageFiles } from './index'
|
||||
|
||||
const file = (overrides: Partial<Express.Multer.File> = {}) => ({
|
||||
fieldname: 'file',
|
||||
originalname: 'photo.jpg',
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/jpeg',
|
||||
size: 123,
|
||||
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xdb]),
|
||||
stream: undefined as any,
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('upload assertions', () => {
|
||||
it('accepts a single image file and narrows the route input', () => {
|
||||
expect(() => assertImageFile(file())).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects missing single file uploads with a field-specific error', () => {
|
||||
expect(() => assertImageFile(undefined, 'license image')).toThrow(ValidationError)
|
||||
expect(() => assertImageFile(undefined, 'license image')).toThrow('A license image is required')
|
||||
})
|
||||
|
||||
it('rejects non-image single file uploads', () => {
|
||||
expect(() => assertImageFile(file({ mimetype: 'application/pdf', originalname: 'license.pdf' }))).toThrow('MIME type does not match file content')
|
||||
})
|
||||
|
||||
it('accepts multiple image files and rejects empty or mixed batches', () => {
|
||||
expect(() => assertImageFiles([file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) })])).not.toThrow()
|
||||
expect(() => assertImageFiles([], 'inspection photos')).toThrow('At least one inspection photos file is required')
|
||||
expect(() => assertImageFiles([
|
||||
file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }),
|
||||
file({ originalname: 'report.pdf', mimetype: 'application/pdf', buffer: Buffer.from('%PDF') }),
|
||||
])).toThrow('Unsupported or spoofed image file')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user