295 lines
8.1 KiB
JavaScript
295 lines
8.1 KiB
JavaScript
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}`;
|
|
}
|
|
}
|