Files
michaelschiemer/public/js/utils/upload.js
Michael Schiemer 5050c7d73a docs: consolidate documentation into organized structure
- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
2025-10-05 11:05:04 +02:00

242 lines
8.0 KiB
JavaScript

/**
* 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<Object>} - 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<Object[]>} - 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();