- 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
242 lines
8.0 KiB
JavaScript
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(); |