UNPKG

svelte-firebase-upload

Version:

Enterprise-grade file upload manager for Svelte with Firebase Storage integration, featuring concurrent uploads, resumable transfers, validation, health monitoring, and plugin system

404 lines (403 loc) 16 kB
/** * Comprehensive file validation utility with security and performance features. * * Features: * - File size and type validation * - Content-based duplicate detection using SHA-256 hashing * - Image dimension extraction * - Video duration detection * - Security checks for dangerous file types * - Custom validation rules support * - Batch validation with error recovery * * @example * ```typescript * const validator = new FileValidator(); * * // Validate single file * const result = await validator.validateFile(file, { * maxSize: 10 * 1024 * 1024, // 10MB * allowedTypes: ['image/*', '.pdf'] * }); * * // Batch validate files * const results = await validator.validateFiles(files, { * maxSize: 5 * 1024 * 1024, * allowedTypes: ['image/jpeg', 'image/png'] * }); * * // Detect duplicates * const duplicates = await validator.detectDuplicates(files); * ``` */ export class FileValidator { // Track object URLs for cleanup _objectUrls = new Set(); // File signature constants for content validation static FILE_SIGNATURES = new Map([ // Images ['image/jpeg', [[0xFF, 0xD8, 0xFF]]], ['image/png', [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]]], ['image/gif', [[0x47, 0x49, 0x46, 0x38], [0x47, 0x49, 0x46, 0x39]]], ['image/webp', [[0x52, 0x49, 0x46, 0x46]]], // Documents ['application/pdf', [[0x25, 0x50, 0x44, 0x46]]], ['application/zip', [[0x50, 0x4B, 0x03, 0x04], [0x50, 0x4B, 0x05, 0x06]]], // Executable files (dangerous) ['application/x-msdownload', [[0x4D, 0x5A]]], ['application/x-executable', [[0x7F, 0x45, 0x4C, 0x46]]], ]); constructor() { // Initialized } _defaultRules = { maxSize: 100 * 1024 * 1024, // 100MB default allowedTypes: ['*/*'] // Allow all types by default }; // Validate a single file against rules async validateFile(file, rules = {}) { const mergedRules = { ...this._defaultRules, ...rules }; const errors = []; const warnings = []; // Size validation if (mergedRules.maxSize && file.size > mergedRules.maxSize) { errors.push(`File size (${this.formatBytes(file.size)}) exceeds maximum allowed size (${this.formatBytes(mergedRules.maxSize)})`); } // Type validation if (mergedRules.allowedTypes && mergedRules.allowedTypes.length > 0) { const isAllowed = this.isFileTypeAllowed(file, [...mergedRules.allowedTypes]); if (!isAllowed) { errors.push(`File type '${file.type}' is not allowed. Allowed types: ${mergedRules.allowedTypes.join(', ')}`); } } // Content validation (virus scan, etc.) if (mergedRules.customValidator) { try { const isValid = await mergedRules.customValidator(file); if (!isValid) { errors.push('File failed custom validation'); } } catch (error) { errors.push(`Custom validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Additional checks const additionalChecks = await this.performAdditionalChecks(file); errors.push(...additionalChecks.errors); warnings.push(...additionalChecks.warnings); // Content-based validation (security) const contentValidation = await this.validateFileContent(file); errors.push(...contentValidation.errors); warnings.push(...contentValidation.warnings); const result = { valid: errors.length === 0, errors, warnings }; if (!result.valid) { console.warn(`[FileValidator] File failed validation: ${file.name}`); console.warn(`[FileValidator] Errors: ${result.errors.join(', ')}`); console.warn(`[FileValidator] Warnings: ${result.warnings.join(', ')}`); } return result; } // Validate multiple files with error recovery async validateFiles(files, rules = {}) { const results = new Map(); // Validate files in parallel for better performance const validationPromises = files.map(async (file) => { try { const result = await this.validateFile(file, rules); results.set(file, result); } catch (error) { console.error(`[FileValidator] Validation failed for file ${file.name}:`, error); // Set a failed validation result instead of crashing results.set(file, { valid: false, errors: [`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`], warnings: [] }); } }); await Promise.allSettled(validationPromises); return results; } // Check for duplicate files based on content hash async detectDuplicates(files) { const hashMap = new Map(); for (const file of files) { const hash = await this.calculateFileHash(file); if (!hashMap.has(hash)) { hashMap.set(hash, []); } hashMap.get(hash).push(file); } // Return only groups with more than one file (duplicates) const duplicates = new Map(); for (const [hash, fileGroup] of hashMap) { if (fileGroup.length > 1) { duplicates.set(hash, fileGroup); } } return duplicates; } // Calculate file hash for duplicate detection with error recovery async calculateFileHash(file, algorithm = 'SHA-256') { const maxAttempts = 3; let attempts = 0; while (attempts < maxAttempts) { try { const buffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest(algorithm, buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); return hash; } catch (error) { attempts++; console.warn(`[FileValidator] Hash calculation attempt ${attempts} failed for file ${file.name}:`, error); if (attempts >= maxAttempts) { // Fallback to simple hash based on file properties console.warn(`[FileValidator] Using fallback hash for file ${file.name}`); const fallbackData = `${file.name}_${file.size}_${file.lastModified}_${file.type}`; const encoder = new TextEncoder(); const data = encoder.encode(fallbackData); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); } // Exponential backoff for retries const delay = Math.min(100 * Math.pow(2, attempts - 1), 1000); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error('Failed to calculate file hash after all attempts'); } // Get file metadata for validation async getFileMetadata(file) { const hash = await this.calculateFileHash(file); const metadata = { size: file.size, type: file.type, lastModified: file.lastModified, hash }; // Get image dimensions if it's an image if (file.type.startsWith('image/')) { const dimensions = await this.getImageDimensions(file); if (dimensions) { metadata.dimensions = dimensions; } } // Get video duration if it's a video if (file.type.startsWith('video/')) { const duration = await this.getVideoDuration(file); if (duration) { metadata.duration = duration; } } return metadata; } // Private methods isFileTypeAllowed(file, allowedTypes) { // Handle wildcard types if (allowedTypes.includes('*/*')) { return true; } for (const allowedType of allowedTypes) { // Exact match if (file.type === allowedType) { return true; } // Wildcard match (e.g., "image/*" matches "image/jpeg") if (allowedType.endsWith('/*')) { const baseType = allowedType.slice(0, -2); if (file.type.startsWith(baseType + '/')) { return true; } } // Extension match (e.g., ".pdf" matches "application/pdf") if (allowedType.startsWith('.')) { const extension = allowedType.toLowerCase(); if (file.name.toLowerCase().endsWith(extension)) { return true; } } } return false; } async performAdditionalChecks(file) { const errors = []; const warnings = []; // Check for empty files if (file.size === 0) { errors.push('File is empty'); } // Check for suspicious file extensions const suspiciousExtensions = ['.exe', '.bat', '.cmd', '.scr', '.pif']; const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')); if (suspiciousExtensions.includes(fileExtension)) { warnings.push(`File has potentially dangerous extension: ${fileExtension}`); } // Check for very large files if (file.size > 1024 * 1024 * 1024) { // 1GB warnings.push('File is very large and may take a long time to upload'); } return { errors, warnings }; } async getImageDimensions(file) { return new Promise((resolve) => { const img = new Image(); const objectUrl = URL.createObjectURL(file); this._objectUrls.add(objectUrl); const cleanup = () => { URL.revokeObjectURL(objectUrl); this._objectUrls.delete(objectUrl); }; img.onload = () => { cleanup(); resolve({ width: img.width, height: img.height }); }; img.onerror = () => { cleanup(); resolve(null); }; img.src = objectUrl; }); } async getVideoDuration(file) { return new Promise((resolve) => { const video = document.createElement('video'); const objectUrl = URL.createObjectURL(file); this._objectUrls.add(objectUrl); const cleanup = () => { URL.revokeObjectURL(objectUrl); this._objectUrls.delete(objectUrl); }; video.onloadedmetadata = () => { cleanup(); resolve(video.duration); }; video.onerror = () => { cleanup(); resolve(null); }; video.src = objectUrl; }); } // Content-based file validation for security async validateFileContent(file) { const errors = []; const warnings = []; try { // Read first 32 bytes for signature check const headerBuffer = await this.readFileHeader(file, 32); const headerBytes = new Uint8Array(headerBuffer); // Check if claimed MIME type matches actual file signature const actualType = this.detectFileTypeFromSignature(headerBytes); if (actualType && actualType !== file.type) { if (this.isDangerousFileType(actualType)) { errors.push(`File appears to be ${actualType} but is masquerading as ${file.type}. This is potentially dangerous.`); } else { warnings.push(`File type mismatch: file appears to be ${actualType} but has type ${file.type}`); } } // Check for executable signatures regardless of claimed type if (this.containsExecutableSignature(headerBytes)) { errors.push('File contains executable code signatures and is not allowed'); } // Check for script content in text files if (file.type.startsWith('text/') || file.name.endsWith('.txt')) { const textContent = await this.readTextSample(file, 1024); if (this.containsScriptContent(textContent)) { warnings.push('Text file contains potentially dangerous script content'); } } } catch (error) { console.warn('[FileValidator] Content validation failed:', error); warnings.push('Could not perform content validation'); } return { errors, warnings }; } async readFileHeader(file, bytes) { const slice = file.slice(0, bytes); return await slice.arrayBuffer(); } async readTextSample(file, bytes) { const slice = file.slice(0, bytes); const text = await slice.text(); return text; } detectFileTypeFromSignature(bytes) { for (const [mimeType, signatures] of FileValidator.FILE_SIGNATURES) { for (const signature of signatures) { if (this.matchesSignature(bytes, signature)) { return mimeType; } } } return null; } matchesSignature(bytes, signature) { if (bytes.length < signature.length) return false; for (let i = 0; i < signature.length; i++) { if (bytes[i] !== signature[i]) return false; } return true; } isDangerousFileType(mimeType) { const dangerousTypes = [ 'application/x-msdownload', 'application/x-executable', 'application/x-dosexec', 'application/x-msdos-program' ]; return dangerousTypes.includes(mimeType); } containsExecutableSignature(bytes) { // Check for common executable signatures const executableSignatures = [ [0x4D, 0x5A], // PE/COFF (Windows exe) [0x7F, 0x45, 0x4C, 0x46], // ELF (Linux/Unix executable) [0xFE, 0xED, 0xFA, 0xCE], // Mach-O (macOS executable) [0xCE, 0xFA, 0xED, 0xFE], // Mach-O (macOS executable, reverse byte order) ]; return executableSignatures.some(signature => this.matchesSignature(bytes, signature)); } containsScriptContent(content) { const scriptPatterns = [ /<script[^>]*>/i, /javascript:/i, /vbscript:/i, /on\w+\s*=/i, // onclick, onload, etc. /eval\s*\(/i, /document\.(write|writeln)\s*\(/i, /window\.(location|open)\s*=/i, ]; return scriptPatterns.some(pattern => pattern.test(content)); } // Cleanup method destroy() { // Revoke all remaining object URLs for (const url of this._objectUrls) { URL.revokeObjectURL(url); } this._objectUrls.clear(); } formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); const formattedBytes = parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return formattedBytes; } }