import { v4 as uuidv4 } from 'uuid'; import Axios from 'axios'; import { baseURLApi } from '../../config'; function extractExtensionFrom(filename) { if (!filename) { return null; } const regex = /(?:\.([^.]+))?$/; return regex.exec(filename)[1]; } /** * Valid MIME type prefixes and specific types for each asset format */ const VALID_MIME_TYPES = { image: { prefixes: ['image/'], extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'], }, video: { prefixes: ['video/'], extensions: ['mp4', 'webm', 'mov', 'avi', 'mkv', 'm4v', 'ogv'], }, audio: { prefixes: ['audio/'], extensions: ['mp3', 'wav', 'ogg', 'aac', 'm4a', 'flac', 'weba'], }, }; /** * Validate that a file matches the expected asset type * @param {File} file - The file to validate * @param {string} expectedType - Expected type: 'image', 'video', or 'audio' * @returns {{ valid: boolean, error?: string }} */ function validateAssetType(file, expectedType) { if (!expectedType || !VALID_MIME_TYPES[expectedType]) { return { valid: true }; } const { prefixes, extensions } = VALID_MIME_TYPES[expectedType]; const mimeType = (file.type || '').toLowerCase(); const extension = extractExtensionFrom(file.name)?.toLowerCase(); // Check MIME type prefix const hasMimeMatch = prefixes.some((prefix) => mimeType.startsWith(prefix)); // Check file extension as fallback (some browsers don't report MIME correctly) const hasExtensionMatch = extension && extensions.includes(extension); if (!hasMimeMatch && !hasExtensionMatch) { const typeLabel = expectedType.charAt(0).toUpperCase() + expectedType.slice(1); return { valid: false, error: `Invalid file type. Expected ${typeLabel} file but got "${mimeType || 'unknown'}" (${file.name})`, }; } return { valid: true }; } export default class FileUploader { /** * Validate file against schema * @param {File} file - File to validate * @param {Object} schema - Validation schema * @param {boolean} [schema.image] - Must be an image * @param {boolean} [schema.video] - Must be a video * @param {boolean} [schema.audio] - Must be audio * @param {string} [schema.assetType] - Asset type: 'image', 'video', 'audio' * @param {number} [schema.size] - Max file size in bytes * @param {string[]} [schema.formats] - Allowed extensions */ static validate(file, schema) { if (!schema) { return; } // Asset type validation (new unified approach) if (schema.assetType) { const result = validateAssetType(file, schema.assetType); if (!result.valid) { throw new Error(result.error); } } // Legacy image validation if (schema.image) { const result = validateAssetType(file, 'image'); if (!result.valid) { throw new Error('You must upload an image'); } } // Legacy video validation if (schema.video) { const result = validateAssetType(file, 'video'); if (!result.valid) { throw new Error('You must upload a video'); } } // Legacy audio validation if (schema.audio) { const result = validateAssetType(file, 'audio'); if (!result.valid) { throw new Error('You must upload an audio file'); } } // File size validation if (schema.size && file.size > schema.size) { const maxSizeMB = (schema.size / (1024 * 1024)).toFixed(1); const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1); throw new Error(`File is too big. Maximum ${maxSizeMB}MB, got ${fileSizeMB}MB`); } // Extension validation const extension = extractExtensionFrom(file.name)?.toLowerCase(); if (schema.formats && extension && !schema.formats.includes(extension)) { throw new Error(`Invalid format. Allowed: ${schema.formats.join(', ')}`); } } static async upload(path, file, schema) { try { this.validate(file, schema); } catch (error) { return Promise.reject(error); } const extension = extractExtensionFrom(file.name); const id = uuidv4(); const filename = `${id}.${extension}`; const privateUrl = `${path}/${filename}`; const publicUrl = await this.uploadToServer(file, path, filename); return { id: id, name: file.name, sizeInBytes: file.size, privateUrl, publicUrl, new: true, }; } static async uploadChunked(path, file, schema, options = {}) { try { this.validate(file, schema); } catch (error) { return Promise.reject(error); } const chunkSize = Number(options.chunkSize) > 0 ? Number(options.chunkSize) : 5 * 1024 * 1024; const maxRetries = Number(options.maxRetries) > 0 ? Number(options.maxRetries) : 3; const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const onStatus = typeof options.onStatus === 'function' ? options.onStatus : null; const signal = options.signal || null; const extension = extractExtensionFrom(file.name); const id = uuidv4(); const filename = extension ? `${id}.${extension}` : id; const privateUrl = `${path}/${filename}`; const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize)); if (signal?.aborted) { throw new Error('Upload aborted'); } const initResponse = await Axios.post( '/file/upload-sessions/init', { folder: path, filename, size: file.size, contentType: file.type || '', totalChunks, }, { signal }, ); const sessionId = initResponse?.data?.sessionId; if (!sessionId) { throw new Error('Upload session was not created'); } for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { if (signal?.aborted) { throw new Error('Upload aborted'); } const start = chunkIndex * chunkSize; const end = Math.min(file.size, start + chunkSize); const chunk = file.slice(start, end); if (onStatus) { onStatus('uploading', { chunkIndex, totalChunks, }); } let retry = 0; while (retry <= maxRetries) { try { await Axios.put( `/file/upload-sessions/${sessionId}/chunks/${chunkIndex}`, chunk, { headers: { 'Content-Type': 'application/octet-stream', }, signal, }, ); break; } catch (error) { if (signal?.aborted) { throw new Error('Upload aborted'); } retry += 1; if (retry > maxRetries) { throw error; } await new Promise((resolve) => setTimeout(resolve, 500 * retry)); } } if (onProgress) { const percent = Math.round(((chunkIndex + 1) / totalChunks) * 100); onProgress(percent, { chunkIndex: chunkIndex + 1, totalChunks, }); } } if (signal?.aborted) { throw new Error('Upload aborted'); } if (onStatus) { onStatus('finalizing', null); } const finalizeResponse = await Axios.post( `/file/upload-sessions/${sessionId}/finalize`, null, { signal }, ); const responsePublicUrl = finalizeResponse?.data?.url; const publicUrl = responsePublicUrl ? responsePublicUrl.startsWith('http') ? responsePublicUrl : `${baseURLApi}${responsePublicUrl}` : `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(privateUrl)}`; return { id, name: file.name, sizeInBytes: file.size, privateUrl, publicUrl, new: true, }; } static async uploadToServer(file, path, filename) { const formData = new FormData(); formData.append('file', file); formData.append('filename', filename); const uri = `/file/upload/${path}`; await Axios.post(uri, formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); const privateUrl = `${path}/${filename}`; return `${baseURLApi}/file/download?privateUrl=${privateUrl}`; } }