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
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

152
public/js/test-upload.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* Test script for JavaScript upload functionality
*/
import uploadManager, { FileValidator } from './utils/upload.js';
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('test-upload');
const uploadButton = document.getElementById('upload-button');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const results = document.getElementById('results');
if (!fileInput || !uploadButton) {
console.log('Upload test elements not found on this page');
return;
}
uploadButton.addEventListener('click', async () => {
const files = Array.from(fileInput.files);
if (files.length === 0) {
showMessage('Please select at least one file', 'error');
return;
}
// Validate files
for (const file of files) {
const errors = FileValidator.validateImage(file);
if (errors.length > 0) {
showMessage(`File ${file.name}: ${errors.join(', ')}`, 'error');
return;
}
}
try {
uploadButton.disabled = true;
showMessage('Starting upload...', 'info');
if (files.length === 1) {
// Single file upload
await uploadSingleFile(files[0]);
} else {
// Multiple file upload
await uploadMultipleFiles(files);
}
} catch (error) {
showMessage(`Upload failed: ${error.message}`, 'error');
} finally {
uploadButton.disabled = false;
progressBar.style.width = '0%';
progressText.textContent = '';
}
});
async function uploadSingleFile(file) {
const result = await uploadManager.uploadImage(file, {
altText: `Uploaded image: ${file.name}`,
onProgress: (percent, loaded, total) => {
updateProgress(percent, `Uploading ${file.name}...`);
}
});
showUploadResult(result, file.name);
}
async function uploadMultipleFiles(files) {
const results = await uploadManager.uploadMultipleImages(files, {
onProgress: (overallPercent, currentIndex, totalFiles) => {
updateProgress(overallPercent, `Uploading file ${currentIndex + 1} of ${totalFiles}...`);
},
onFileComplete: (result, index, total) => {
showMessage(`Completed ${index + 1}/${total}: ${files[index].name}`, 'success');
}
});
showMessage(`Upload completed. ${results.filter(r => r.success).length}/${results.length} files successful`, 'info');
results.forEach(result => {
showUploadResult(result.success ? result.data : null, result.file, result.error);
});
}
function updateProgress(percent, text) {
progressBar.style.width = `${percent}%`;
progressText.textContent = `${text} (${Math.round(percent)}%)`;
}
function showMessage(message, type = 'info') {
const messageDiv = document.createElement('div');
messageDiv.className = `message message-${type}`;
messageDiv.textContent = message;
results.insertBefore(messageDiv, results.firstChild);
// Remove after 5 seconds
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.remove();
}
}, 5000);
}
function showUploadResult(uploadData, fileName, error = null) {
const resultDiv = document.createElement('div');
resultDiv.className = 'upload-result';
if (error) {
resultDiv.innerHTML = `
<h4>❌ ${fileName}</h4>
<p class="error">Error: ${error}</p>
`;
} else {
resultDiv.innerHTML = `
<h4>✅ ${fileName}</h4>
<div class="result-details">
<p><strong>ULID:</strong> ${uploadData.ulid}</p>
<p><strong>Filename:</strong> ${uploadData.filename}</p>
<p><strong>Size:</strong> ${uploadData.file_size.human_readable}</p>
<p><strong>Dimensions:</strong> ${uploadData.dimensions.width}x${uploadData.dimensions.height}</p>
<p><strong>MIME Type:</strong> ${uploadData.mime_type}</p>
<img src="${uploadData.thumbnail_url}" alt="${uploadData.alt_text}" style="max-width: 150px; margin-top: 10px;">
</div>
`;
}
results.appendChild(resultDiv);
}
});
// Test CSRF token functionality
async function testCsrfTokens() {
try {
console.log('Testing CSRF token generation...');
const tokens = await uploadManager.getCsrfTokens('/api/images', 'post');
console.log('CSRF tokens received:', {
form_id: tokens.form_id,
token: tokens.token.substring(0, 10) + '...',
headers: tokens.headers
});
return tokens;
} catch (error) {
console.error('CSRF token test failed:', error);
throw error;
}
}
// Make test function available globally for console testing
window.testCsrfTokens = testCsrfTokens;
window.uploadManager = uploadManager;
window.FileValidator = FileValidator;

242
public/js/utils/upload.js Normal file
View File

@@ -0,0 +1,242 @@
/**
* 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();