202 lines
5.6 KiB
TypeScript
Executable file
202 lines
5.6 KiB
TypeScript
Executable file
/**
|
|
* Image Validation Functions
|
|
*
|
|
* Pure validation functions that work in both browser and Node.js.
|
|
* Uses Uint8Array instead of Buffer for browser compatibility.
|
|
*
|
|
* @example Browser usage:
|
|
* ```typescript
|
|
* const file = inputEl.files[0]
|
|
* const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer())
|
|
* const result = validateImageBytes(bytes, file.type)
|
|
* if (!result.valid) {
|
|
* alert(result.error)
|
|
* }
|
|
* ```
|
|
*
|
|
* @example Node.js usage:
|
|
* ```typescript
|
|
* const buffer = await fs.readFile('image.jpg')
|
|
* const bytes = new Uint8Array(buffer)
|
|
* const result = validateImageBytes(bytes, 'image/jpeg')
|
|
* ```
|
|
*/
|
|
|
|
import {
|
|
ALLOWED_IMAGE_MIME_TYPES,
|
|
IMAGE_MAGIC_BYTES,
|
|
WEBP_SIGNATURE,
|
|
DEFAULT_MAX_FILE_SIZE_BYTES,
|
|
type AllowedImageMimeType,
|
|
type ImageValidationResult,
|
|
} from '../types'
|
|
|
|
/**
|
|
* Check if a MIME type is in the allowed list.
|
|
*
|
|
* @param mimeType - The MIME type to check
|
|
* @returns Type guard indicating if the MIME type is allowed
|
|
*/
|
|
export function isAllowedMimeType(mimeType: string): mimeType is AllowedImageMimeType {
|
|
return ALLOWED_IMAGE_MIME_TYPES.includes(mimeType as AllowedImageMimeType)
|
|
}
|
|
|
|
/**
|
|
* Check if a file size is within the allowed limit.
|
|
*
|
|
* @param sizeBytes - File size in bytes
|
|
* @param maxBytes - Maximum allowed size (default: 5MB)
|
|
* @returns Whether the file size is valid
|
|
*/
|
|
export function isValidFileSize(
|
|
sizeBytes: number,
|
|
maxBytes: number = DEFAULT_MAX_FILE_SIZE_BYTES
|
|
): boolean {
|
|
return sizeBytes > 0 && sizeBytes <= maxBytes
|
|
}
|
|
|
|
/**
|
|
* Validate magic bytes against a claimed MIME type.
|
|
* Returns true if the bytes match any valid signature for the MIME type.
|
|
*
|
|
* @param bytes - First 12 bytes of the file (Uint8Array for browser compat)
|
|
* @param claimedMimeType - The MIME type claimed by the file/upload
|
|
* @returns Whether the magic bytes match the claimed type
|
|
*/
|
|
export function validateMagicBytes(
|
|
bytes: Uint8Array,
|
|
claimedMimeType: AllowedImageMimeType
|
|
): boolean {
|
|
const signatures = IMAGE_MAGIC_BYTES[claimedMimeType]
|
|
|
|
if (!signatures) {
|
|
return false
|
|
}
|
|
|
|
const matches = signatures.some((sig) => sig.every((byte, i) => bytes[i] === byte))
|
|
|
|
// Special case for WebP: also check for WEBP signature at offset 8
|
|
if (claimedMimeType === 'image/webp' && matches) {
|
|
return WEBP_SIGNATURE.every((byte, i) => bytes[8 + i] === byte)
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
/**
|
|
* Detect MIME type from magic bytes.
|
|
* Examines the first bytes of a file to determine its actual type.
|
|
*
|
|
* @param bytes - First 12 bytes of the file
|
|
* @returns Detected MIME type, or null if not recognized
|
|
*/
|
|
export function detectMimeTypeFromBytes(bytes: Uint8Array): AllowedImageMimeType | null {
|
|
for (const [mimeType, signatures] of Object.entries(IMAGE_MAGIC_BYTES)) {
|
|
const matches = (signatures as number[][]).some((sig) =>
|
|
sig.every((byte, i) => bytes[i] === byte)
|
|
)
|
|
|
|
if (matches) {
|
|
// Special case for WebP: verify WEBP signature at offset 8
|
|
if (mimeType === 'image/webp') {
|
|
if (!WEBP_SIGNATURE.every((byte, i) => bytes[8 + i] === byte)) {
|
|
continue
|
|
}
|
|
}
|
|
return mimeType as AllowedImageMimeType
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Comprehensive image validation.
|
|
* Validates MIME type, detects actual type from bytes, and checks for mismatches.
|
|
*
|
|
* @param bytes - First 12 bytes of the file (minimum)
|
|
* @param claimedMimeType - The MIME type claimed by the file/upload
|
|
* @returns Validation result with error details if invalid
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const file = inputEl.files[0]
|
|
* const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer())
|
|
* const result = validateImageBytes(bytes, file.type)
|
|
*
|
|
* if (!result.valid) {
|
|
* console.error('Validation failed:', result.error)
|
|
* } else {
|
|
* console.log('Detected type:', result.detectedMimeType)
|
|
* }
|
|
* ```
|
|
*/
|
|
export function validateImageBytes(
|
|
bytes: Uint8Array,
|
|
claimedMimeType: string
|
|
): ImageValidationResult {
|
|
// Check if claimed MIME type is allowed
|
|
if (!isAllowedMimeType(claimedMimeType)) {
|
|
return {
|
|
valid: false,
|
|
error: `MIME type '${claimedMimeType}' is not allowed. Allowed types: ${ALLOWED_IMAGE_MIME_TYPES.join(', ')}`,
|
|
}
|
|
}
|
|
|
|
// Need at least 12 bytes to validate (WebP requires checking offset 8-11)
|
|
if (bytes.length < 12) {
|
|
return {
|
|
valid: false,
|
|
error: 'Insufficient bytes for validation. Need at least 12 bytes.',
|
|
}
|
|
}
|
|
|
|
// Detect actual MIME type from magic bytes
|
|
const detectedMimeType = detectMimeTypeFromBytes(bytes)
|
|
|
|
if (!detectedMimeType) {
|
|
return {
|
|
valid: false,
|
|
error: 'Could not detect image type from file contents. The file may be corrupted or not a valid image.',
|
|
}
|
|
}
|
|
|
|
// Check if detected type matches claimed type
|
|
if (detectedMimeType !== claimedMimeType) {
|
|
return {
|
|
valid: false,
|
|
error: `File content does not match claimed type. Claimed: ${claimedMimeType}, Detected: ${detectedMimeType}`,
|
|
detectedMimeType,
|
|
}
|
|
}
|
|
|
|
// Validate magic bytes against claimed type
|
|
if (!validateMagicBytes(bytes, claimedMimeType)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Magic bytes validation failed. The file may be corrupted or tampered with.',
|
|
detectedMimeType,
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
detectedMimeType,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format file size as human-readable string.
|
|
*
|
|
* @param sizeBytes - File size in bytes
|
|
* @returns Human-readable file size (e.g., "2.5 MB")
|
|
*/
|
|
export function formatFileSize(sizeBytes: number): string {
|
|
if (sizeBytes < 1024) {
|
|
return `${sizeBytes} B`
|
|
}
|
|
if (sizeBytes < 1024 * 1024) {
|
|
return `${(sizeBytes / 1024).toFixed(1)} KB`
|
|
}
|
|
return `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB`
|
|
}
|