/** * Upload utility with CSRF support for the Custom PHP Framework */ export class UploadManager { constructor(baseUrl = '') { this.baseUrl = baseUrl; this.csrfCache = new Map(); } /** * Get CSRF tokens for a form * @param {string} action - Form action URL * @param {string} method - Form method (default: 'post') * @returns {Promise<{form_id: string, token: string, headers: Object}>} */ async getCsrfTokens(action = '/', method = 'post') { const cacheKey = `${action}:${method}`; if (this.csrfCache.has(cacheKey)) { return this.csrfCache.get(cacheKey); } try { const response = await fetch(`${this.baseUrl}/api/csrf/token?action=${encodeURIComponent(action)}&method=${encodeURIComponent(method)}`, { method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': navigator.userAgent } }); if (!response.ok) { throw new Error(`CSRF token request failed: ${response.status}`); } const tokens = await response.json(); // Cache tokens for reuse this.csrfCache.set(cacheKey, tokens); return tokens; } catch (error) { console.error('Failed to get CSRF tokens:', error); throw error; } } /** * Upload an image file with CSRF protection * @param {File} file - The image file to upload * @param {Object} options - Upload options * @param {string} options.altText - Alt text for the image * @param {Function} options.onProgress - Progress callback * @param {AbortSignal} options.signal - Abort signal for cancellation * @returns {Promise} - Upload result */ async uploadImage(file, options = {}) { const { altText = '', onProgress, signal } = options; try { // Get CSRF tokens const csrfTokens = await this.getCsrfTokens('/api/images', 'post'); // Note: Honeypot validation is disabled for API routes // API routes use CSRF tokens for protection instead // Validate file if (!file || !(file instanceof File)) { throw new Error('Invalid file provided'); } if (!file.type.startsWith('image/')) { throw new Error('File must be an image'); } // Create FormData const formData = new FormData(); formData.append('image', file); if (altText) { formData.append('alt_text', altText); } // Note: No honeypot fields needed for API routes // API routes are protected by CSRF tokens in headers // Create upload request const xhr = new XMLHttpRequest(); return new Promise((resolve, reject) => { // Handle progress if (onProgress) { xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const percentComplete = (event.loaded / event.total) * 100; onProgress(percentComplete, event.loaded, event.total); } }); } // Handle completion xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const result = JSON.parse(xhr.responseText); resolve(result); } catch (parseError) { reject(new Error('Invalid JSON response')); } } else { try { const errorData = JSON.parse(xhr.responseText); reject(new Error(errorData.message || `Upload failed with status ${xhr.status}`)); } catch { reject(new Error(`Upload failed with status ${xhr.status}`)); } } }); // Handle errors xhr.addEventListener('error', () => { reject(new Error('Network error during upload')); }); xhr.addEventListener('abort', () => { reject(new Error('Upload was cancelled')); }); // Handle cancellation if (signal) { signal.addEventListener('abort', () => { xhr.abort(); }); } // Open and send request xhr.open('POST', `${this.baseUrl}/api/images`); // Set CSRF headers xhr.setRequestHeader('X-CSRF-Form-ID', csrfTokens.form_id); xhr.setRequestHeader('X-CSRF-Token', csrfTokens.token); xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('User-Agent', navigator.userAgent); xhr.send(formData); }); } catch (error) { console.error('Upload failed:', error); throw error; } } /** * Upload multiple images with progress tracking * @param {File[]} files - Array of image files * @param {Object} options - Upload options * @returns {Promise} - Array of upload results */ async uploadMultipleImages(files, options = {}) { const { onProgress, onFileComplete, signal } = options; const results = []; let completedCount = 0; for (const [index, file] of files.entries()) { try { const fileOptions = { ...options, onProgress: (percent, loaded, total) => { if (onProgress) { const overallProgress = ((completedCount + (percent / 100)) / files.length) * 100; onProgress(overallProgress, index, files.length); } }, signal }; const result = await this.uploadImage(file, fileOptions); results.push({ success: true, file: file.name, data: result }); completedCount++; if (onFileComplete) { onFileComplete(result, index, files.length); } } catch (error) { results.push({ success: false, file: file.name, error: error.message }); completedCount++; } } return results; } /** * Clear CSRF token cache */ clearCsrfCache() { this.csrfCache.clear(); } } /** * File validation utilities */ export class FileValidator { static MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB static ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; static validateImage(file) { const errors = []; if (!file || !(file instanceof File)) { errors.push('Invalid file'); return errors; } // Check file size if (file.size > this.MAX_FILE_SIZE) { errors.push(`File size must be less than ${this.MAX_FILE_SIZE / (1024 * 1024)}MB`); } // Check file type if (!this.ALLOWED_TYPES.includes(file.type)) { errors.push(`File type ${file.type} is not allowed. Allowed types: ${this.ALLOWED_TYPES.join(', ')}`); } // Check file name if (file.name.length > 255) { errors.push('File name is too long (max 255 characters)'); } return errors; } static isValidImage(file) { return this.validateImage(file).length === 0; } } // Export a default instance export default new UploadManager();