2026-03-30 21:28:00 +04:00

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}`;
}
}