- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
639 lines
20 KiB
JavaScript
639 lines
20 KiB
JavaScript
/**
|
|
* ComponentFileUploader - File Upload Module for LiveComponents
|
|
*
|
|
* Features:
|
|
* - Drag & Drop support
|
|
* - Multi-file uploads with queue management
|
|
* - Progress tracking (per-file and overall)
|
|
* - Image/document previews
|
|
* - Client-side validation
|
|
* - CSRF protection
|
|
* - Integration with LiveComponent state management
|
|
*
|
|
* @package Framework\LiveComponents
|
|
*/
|
|
|
|
/**
|
|
* File Upload Progress Tracker
|
|
*/
|
|
export class UploadProgress {
|
|
constructor(file, fileId) {
|
|
this.file = file;
|
|
this.fileId = fileId;
|
|
this.uploadedBytes = 0;
|
|
this.totalBytes = file.size;
|
|
this.status = 'pending'; // pending, uploading, processing, complete, error
|
|
this.error = null;
|
|
this.xhr = null;
|
|
this.startTime = null;
|
|
this.endTime = null;
|
|
}
|
|
|
|
get percentage() {
|
|
if (this.totalBytes === 0) return 100;
|
|
return Math.round((this.uploadedBytes / this.totalBytes) * 100);
|
|
}
|
|
|
|
get isComplete() {
|
|
return this.status === 'complete';
|
|
}
|
|
|
|
get hasError() {
|
|
return this.status === 'error';
|
|
}
|
|
|
|
get isUploading() {
|
|
return this.status === 'uploading';
|
|
}
|
|
|
|
get uploadSpeed() {
|
|
if (!this.startTime || !this.isUploading) return 0;
|
|
const elapsed = (Date.now() - this.startTime) / 1000; // seconds
|
|
return elapsed > 0 ? this.uploadedBytes / elapsed : 0; // bytes per second
|
|
}
|
|
|
|
get remainingTime() {
|
|
const speed = this.uploadSpeed;
|
|
if (speed === 0) return 0;
|
|
const remaining = this.totalBytes - this.uploadedBytes;
|
|
return remaining / speed; // seconds
|
|
}
|
|
|
|
updateProgress(loaded, total) {
|
|
this.uploadedBytes = loaded;
|
|
this.totalBytes = total;
|
|
}
|
|
|
|
setStatus(status, error = null) {
|
|
this.status = status;
|
|
this.error = error;
|
|
|
|
if (status === 'uploading' && !this.startTime) {
|
|
this.startTime = Date.now();
|
|
}
|
|
|
|
if (status === 'complete' || status === 'error') {
|
|
this.endTime = Date.now();
|
|
}
|
|
}
|
|
|
|
abort() {
|
|
if (this.xhr) {
|
|
this.xhr.abort();
|
|
this.setStatus('error', 'Upload cancelled');
|
|
}
|
|
}
|
|
|
|
toObject() {
|
|
return {
|
|
fileId: this.fileId,
|
|
fileName: this.file.name,
|
|
fileSize: this.totalBytes,
|
|
uploadedBytes: this.uploadedBytes,
|
|
percentage: this.percentage,
|
|
status: this.status,
|
|
error: this.error,
|
|
uploadSpeed: this.uploadSpeed,
|
|
remainingTime: this.remainingTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* File Validator - Client-side validation
|
|
*/
|
|
export class FileValidator {
|
|
constructor(options = {}) {
|
|
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB default
|
|
this.allowedMimeTypes = options.allowedMimeTypes || [];
|
|
this.allowedExtensions = options.allowedExtensions || [];
|
|
this.minFileSize = options.minFileSize || 1; // 1 byte minimum
|
|
}
|
|
|
|
validate(file) {
|
|
const errors = [];
|
|
|
|
// File size validation
|
|
if (file.size > this.maxFileSize) {
|
|
errors.push(`File size (${this.formatBytes(file.size)}) exceeds maximum allowed size (${this.formatBytes(this.maxFileSize)})`);
|
|
}
|
|
|
|
if (file.size < this.minFileSize) {
|
|
errors.push(`File size is too small (minimum: ${this.formatBytes(this.minFileSize)})`);
|
|
}
|
|
|
|
// MIME type validation
|
|
if (this.allowedMimeTypes.length > 0 && !this.allowedMimeTypes.includes(file.type)) {
|
|
errors.push(`File type "${file.type}" is not allowed. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
|
|
}
|
|
|
|
// Extension validation
|
|
if (this.allowedExtensions.length > 0) {
|
|
const extension = file.name.split('.').pop().toLowerCase();
|
|
if (!this.allowedExtensions.includes(extension)) {
|
|
errors.push(`File extension ".${extension}" is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// File name validation
|
|
if (file.name.length > 255) {
|
|
errors.push('File name is too long (maximum 255 characters)');
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
isValid(file) {
|
|
return this.validate(file).length === 0;
|
|
}
|
|
|
|
formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drag & Drop Zone Manager
|
|
*/
|
|
export class DragDropZone {
|
|
constructor(element, callbacks = {}) {
|
|
this.element = element;
|
|
this.onFilesDropped = callbacks.onFilesDropped || (() => {});
|
|
this.onDragEnter = callbacks.onDragEnter || (() => {});
|
|
this.onDragLeave = callbacks.onDragLeave || (() => {});
|
|
|
|
this.dragCounter = 0; // Track nested drag events
|
|
this.isActive = false;
|
|
|
|
this.bindEvents();
|
|
}
|
|
|
|
bindEvents() {
|
|
// Prevent default browser behavior for drag events
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
this.element.addEventListener(eventName, (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
});
|
|
|
|
// Handle drag enter
|
|
this.element.addEventListener('dragenter', (e) => {
|
|
this.dragCounter++;
|
|
if (this.dragCounter === 1) {
|
|
this.isActive = true;
|
|
this.element.classList.add('drag-over');
|
|
this.onDragEnter(e);
|
|
}
|
|
});
|
|
|
|
// Handle drag leave
|
|
this.element.addEventListener('dragleave', (e) => {
|
|
this.dragCounter--;
|
|
if (this.dragCounter === 0) {
|
|
this.isActive = false;
|
|
this.element.classList.remove('drag-over');
|
|
this.onDragLeave(e);
|
|
}
|
|
});
|
|
|
|
// Handle drag over
|
|
this.element.addEventListener('dragover', (e) => {
|
|
// Required to allow drop
|
|
});
|
|
|
|
// Handle drop
|
|
this.element.addEventListener('drop', (e) => {
|
|
this.dragCounter = 0;
|
|
this.isActive = false;
|
|
this.element.classList.remove('drag-over');
|
|
|
|
const files = Array.from(e.dataTransfer?.files || []);
|
|
if (files.length > 0) {
|
|
this.onFilesDropped(files);
|
|
}
|
|
});
|
|
}
|
|
|
|
destroy() {
|
|
this.element.classList.remove('drag-over');
|
|
// Event listeners are automatically removed when element is removed
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main ComponentFileUploader Class
|
|
*/
|
|
export class ComponentFileUploader {
|
|
constructor(componentElement, options = {}) {
|
|
this.componentElement = componentElement;
|
|
this.componentId = componentElement.dataset.liveId;
|
|
|
|
// Options
|
|
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
|
|
this.allowedMimeTypes = options.allowedMimeTypes || [];
|
|
this.allowedExtensions = options.allowedExtensions || [];
|
|
this.maxFiles = options.maxFiles || 10;
|
|
this.autoUpload = options.autoUpload !== false; // default: true
|
|
this.multiple = options.multiple !== false; // default: true
|
|
this.endpoint = options.endpoint || `/live-component/${this.componentId}/upload`;
|
|
|
|
// Callbacks
|
|
this.onFileAdded = options.onFileAdded || (() => {});
|
|
this.onFileRemoved = options.onFileRemoved || (() => {});
|
|
this.onUploadStart = options.onUploadStart || (() => {});
|
|
this.onUploadProgress = options.onUploadProgress || (() => {});
|
|
this.onUploadComplete = options.onUploadComplete || (() => {});
|
|
this.onUploadError = options.onUploadError || (() => {});
|
|
this.onAllUploadsComplete = options.onAllUploadsComplete || (() => {});
|
|
|
|
// State
|
|
this.files = new Map(); // Map<fileId, UploadProgress>
|
|
this.uploadQueue = [];
|
|
this.activeUploads = 0;
|
|
this.maxConcurrentUploads = options.maxConcurrentUploads || 2;
|
|
|
|
// Validator
|
|
this.validator = new FileValidator({
|
|
maxFileSize: this.maxFileSize,
|
|
allowedMimeTypes: this.allowedMimeTypes,
|
|
allowedExtensions: this.allowedExtensions
|
|
});
|
|
|
|
// UI Elements (optional)
|
|
this.dropZoneElement = options.dropZone;
|
|
this.fileInputElement = options.fileInput;
|
|
|
|
this.initialize();
|
|
}
|
|
|
|
initialize() {
|
|
// Setup drag & drop if dropZone provided
|
|
if (this.dropZoneElement) {
|
|
this.dragDropZone = new DragDropZone(this.dropZoneElement, {
|
|
onFilesDropped: (files) => this.addFiles(files),
|
|
onDragEnter: () => this.dropZoneElement.classList.add('drag-active'),
|
|
onDragLeave: () => this.dropZoneElement.classList.remove('drag-active')
|
|
});
|
|
}
|
|
|
|
// Setup file input if provided
|
|
if (this.fileInputElement) {
|
|
this.fileInputElement.addEventListener('change', (e) => {
|
|
const files = Array.from(e.target.files || []);
|
|
this.addFiles(files);
|
|
e.target.value = ''; // Reset input
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add files to upload queue
|
|
*/
|
|
addFiles(files) {
|
|
const filesToAdd = Array.isArray(files) ? files : [files];
|
|
|
|
// Check max files limit
|
|
if (this.files.size + filesToAdd.length > this.maxFiles) {
|
|
const error = `Cannot add files. Maximum ${this.maxFiles} files allowed.`;
|
|
this.onUploadError({ error, fileCount: filesToAdd.length });
|
|
return;
|
|
}
|
|
|
|
for (const file of filesToAdd) {
|
|
// Generate unique file ID
|
|
const fileId = this.generateFileId(file);
|
|
|
|
// Validate file
|
|
const validationErrors = this.validator.validate(file);
|
|
|
|
const progress = new UploadProgress(file, fileId);
|
|
|
|
if (validationErrors.length > 0) {
|
|
progress.setStatus('error', validationErrors.join(', '));
|
|
this.files.set(fileId, progress);
|
|
this.onUploadError({
|
|
fileId,
|
|
file,
|
|
errors: validationErrors
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Add to files map
|
|
this.files.set(fileId, progress);
|
|
this.uploadQueue.push(fileId);
|
|
|
|
// Callback
|
|
this.onFileAdded({
|
|
fileId,
|
|
file,
|
|
progress: progress.toObject()
|
|
});
|
|
}
|
|
|
|
// Auto-upload if enabled
|
|
if (this.autoUpload) {
|
|
this.processQueue();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove file from queue
|
|
*/
|
|
removeFile(fileId) {
|
|
const progress = this.files.get(fileId);
|
|
if (!progress) return;
|
|
|
|
// Abort if uploading
|
|
if (progress.isUploading) {
|
|
progress.abort();
|
|
}
|
|
|
|
// Remove from queue
|
|
const queueIndex = this.uploadQueue.indexOf(fileId);
|
|
if (queueIndex !== -1) {
|
|
this.uploadQueue.splice(queueIndex, 1);
|
|
}
|
|
|
|
// Remove from files map
|
|
this.files.delete(fileId);
|
|
|
|
// Callback
|
|
this.onFileRemoved({ fileId, file: progress.file });
|
|
}
|
|
|
|
/**
|
|
* Start uploading all queued files
|
|
*/
|
|
uploadAll() {
|
|
this.processQueue();
|
|
}
|
|
|
|
/**
|
|
* Process upload queue
|
|
*/
|
|
async processQueue() {
|
|
while (this.uploadQueue.length > 0 && this.activeUploads < this.maxConcurrentUploads) {
|
|
const fileId = this.uploadQueue.shift();
|
|
const progress = this.files.get(fileId);
|
|
|
|
if (!progress || progress.status !== 'pending') continue;
|
|
|
|
this.activeUploads++;
|
|
this.uploadFile(fileId).finally(() => {
|
|
this.activeUploads--;
|
|
this.processQueue(); // Process next file
|
|
|
|
// Check if all uploads are complete
|
|
if (this.activeUploads === 0 && this.uploadQueue.length === 0) {
|
|
this.onAllUploadsComplete({
|
|
totalFiles: this.files.size,
|
|
successCount: Array.from(this.files.values()).filter(p => p.isComplete).length,
|
|
errorCount: Array.from(this.files.values()).filter(p => p.hasError).length
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload a single file
|
|
*/
|
|
async uploadFile(fileId) {
|
|
const progress = this.files.get(fileId);
|
|
if (!progress) return;
|
|
|
|
try {
|
|
// Get current component state
|
|
const componentState = this.getComponentState();
|
|
|
|
// Get CSRF tokens
|
|
const csrfTokens = await this.getCsrfTokens();
|
|
|
|
// Create FormData
|
|
const formData = new FormData();
|
|
formData.append('file', progress.file);
|
|
formData.append('state', JSON.stringify(componentState));
|
|
formData.append('params', JSON.stringify({ fileId }));
|
|
|
|
// Create XHR request
|
|
const xhr = new XMLHttpRequest();
|
|
progress.xhr = xhr;
|
|
|
|
// Setup progress tracking
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
progress.updateProgress(e.loaded, e.total);
|
|
this.onUploadProgress({
|
|
fileId,
|
|
...progress.toObject()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Setup completion handler
|
|
const uploadPromise = new Promise((resolve, reject) => {
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
try {
|
|
const response = JSON.parse(xhr.responseText);
|
|
if (response.success) {
|
|
progress.setStatus('complete');
|
|
|
|
// Update component state if provided
|
|
if (response.state) {
|
|
this.updateComponentState(response.state);
|
|
}
|
|
|
|
// Update component HTML if provided
|
|
if (response.html) {
|
|
this.updateComponentHtml(response.html);
|
|
}
|
|
|
|
this.onUploadComplete({
|
|
fileId,
|
|
file: progress.file,
|
|
response
|
|
});
|
|
|
|
resolve(response);
|
|
} else {
|
|
throw new Error(response.error || 'Upload failed');
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
} else {
|
|
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', () => {
|
|
reject(new Error('Network error during upload'));
|
|
});
|
|
|
|
xhr.addEventListener('abort', () => {
|
|
reject(new Error('Upload cancelled'));
|
|
});
|
|
});
|
|
|
|
// Set status to uploading
|
|
progress.setStatus('uploading');
|
|
this.onUploadStart({ fileId, file: progress.file });
|
|
|
|
// Open and send request
|
|
xhr.open('POST', this.endpoint);
|
|
|
|
// 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);
|
|
|
|
await uploadPromise;
|
|
|
|
} catch (error) {
|
|
progress.setStatus('error', error.message);
|
|
this.onUploadError({
|
|
fileId,
|
|
file: progress.file,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel all uploads
|
|
*/
|
|
cancelAll() {
|
|
this.uploadQueue = [];
|
|
this.files.forEach(progress => {
|
|
if (progress.isUploading) {
|
|
progress.abort();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear all files (completed and pending)
|
|
*/
|
|
clearAll() {
|
|
this.cancelAll();
|
|
this.files.clear();
|
|
}
|
|
|
|
/**
|
|
* Get overall upload progress
|
|
*/
|
|
getOverallProgress() {
|
|
if (this.files.size === 0) return 100;
|
|
|
|
const totalBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.totalBytes, 0);
|
|
const uploadedBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.uploadedBytes, 0);
|
|
|
|
return totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0;
|
|
}
|
|
|
|
/**
|
|
* Get upload statistics
|
|
*/
|
|
getStats() {
|
|
const files = Array.from(this.files.values());
|
|
return {
|
|
total: files.length,
|
|
pending: files.filter(p => p.status === 'pending').length,
|
|
uploading: files.filter(p => p.status === 'uploading').length,
|
|
complete: files.filter(p => p.status === 'complete').length,
|
|
error: files.filter(p => p.status === 'error').length,
|
|
overallProgress: this.getOverallProgress()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper: Generate unique file ID
|
|
*/
|
|
generateFileId(file) {
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.name}`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Get CSRF tokens
|
|
*/
|
|
async getCsrfTokens() {
|
|
try {
|
|
const response = await fetch(`/api/csrf/token?action=${encodeURIComponent(this.endpoint)}&method=post`, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'User-Agent': navigator.userAgent
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`CSRF token request failed: ${response.status}`);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Failed to get CSRF tokens:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper: Get component state from DOM
|
|
*/
|
|
getComponentState() {
|
|
const stateElement = this.componentElement.querySelector('[data-live-state]');
|
|
if (stateElement) {
|
|
try {
|
|
return JSON.parse(stateElement.textContent || '{}');
|
|
} catch (e) {
|
|
console.warn('Failed to parse component state:', e);
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Helper: Update component state in DOM
|
|
*/
|
|
updateComponentState(newState) {
|
|
const stateElement = this.componentElement.querySelector('[data-live-state]');
|
|
if (stateElement) {
|
|
stateElement.textContent = JSON.stringify(newState);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper: Update component HTML
|
|
*/
|
|
updateComponentHtml(html) {
|
|
// Find the component content container (usually the component itself)
|
|
const contentElement = this.componentElement.querySelector('[data-live-content]') || this.componentElement;
|
|
if (contentElement) {
|
|
contentElement.innerHTML = html;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup
|
|
*/
|
|
destroy() {
|
|
this.cancelAll();
|
|
this.clearAll();
|
|
|
|
if (this.dragDropZone) {
|
|
this.dragDropZone.destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
export default ComponentFileUploader;
|