UNPKG

@mvp-factory/holy-upload

Version:

File upload processing system extracted from Holy Habit project with security validation and image optimization

432 lines 15.9 kB
"use strict"; /** * Upload Handler * * Main handler for file upload operations * Extracted from Holy Habit upload system */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.UploadHandler = void 0; const Upload_1 = require("../types/Upload"); const UploadConfig_1 = require("../config/UploadConfig"); const FileValidator_1 = require("../validators/FileValidator"); const ImageOptimizer_1 = require("../utils/ImageOptimizer"); const FileUtils_1 = require("../utils/FileUtils"); const path = __importStar(require("path")); const fs = __importStar(require("fs")); class UploadHandler { constructor(config) { this.config = UploadConfig_1.UploadConfigManager.merge(UploadConfig_1.UploadConfigManager.load(), config || {}); this.uploadDir = UploadConfig_1.UploadConfigManager.getAbsoluteUploadDir(this.config); } /** * Upload single file * * @param file - Multer file object * @param userId - User ID (optional) * @param options - Upload options * @returns Upload result */ async uploadSingle(file, userId, options = {}) { try { // Ensure upload directory exists await FileUtils_1.FileUtils.ensureDirectory(this.uploadDir); // Validate file const validationOptions = { maxSize: this.config.maxSize, allowedMimeTypes: this.config.allowedMimeTypes, allowedExtensions: this.config.allowedExtensions, checkMalicious: true, ...options.validation }; const validation = await FileValidator_1.FileValidator.validate(file, validationOptions); if (!validation.isValid) { const error = FileValidator_1.FileValidator.createError(validation); return { success: false, error: error.message }; } // Generate secure filename const filename = FileUtils_1.FileUtils.generateSecureFilename(file.originalname, userId, options.prefix); const filePath = path.join(this.uploadDir, filename); // Save file if (file.buffer) { await fs.promises.writeFile(filePath, file.buffer); } else if (file.path) { await FileUtils_1.FileUtils.moveFile(file.path, filePath); } else { throw new Upload_1.UploadError('No file data available', 'NO_FILE_DATA'); } // Optimize image if enabled and file is an image if (this.config.enableOptimization && this.isImageFile(validation.mimeType)) { try { const optimizationOptions = { maxWidth: this.config.maxWidth, maxHeight: this.config.maxHeight, jpegQuality: this.config.jpegQuality, pngCompression: this.config.pngCompression, webpQuality: this.config.webpQuality, ...options.optimization }; await ImageOptimizer_1.ImageOptimizer.optimize(filePath, filePath, optimizationOptions); } catch (error) { // Optimization failed, but file is still valid console.warn('Image optimization failed:', error); } } // Get final file stats const stats = await fs.promises.stat(filePath); // Create uploaded file object const uploadedFile = { filename, originalName: file.originalname, mimeType: validation.mimeType, size: stats.size, extension: validation.extension, uploadedAt: new Date(), userId, path: filePath, url: this.getFileUrl(filename) }; return { success: true, file: uploadedFile, url: uploadedFile.url }; } catch (error) { if (error instanceof Upload_1.UploadError) { return { success: false, error: error.message }; } return { success: false, error: `Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Upload multiple files * * @param files - Array of Multer file objects * @param userId - User ID (optional) * @param options - Upload options * @returns Multiple upload result */ async uploadMultiple(files, userId, options = {}) { const results = []; const errors = []; for (let i = 0; i < files.length; i++) { const file = files[i]; try { const result = await this.uploadSingle(file, userId, { validation: options.validation, optimization: options.optimization, prefix: options.prefix }); if (result.success && result.file) { results.push(result.file); } else { const errorMessage = `File ${i + 1} (${file.originalname}): ${result.error}`; errors.push(errorMessage); if (!options.continueOnError) { break; } } } catch (error) { const errorMessage = `File ${i + 1} (${file.originalname}): ${error instanceof Error ? error.message : 'Unknown error'}`; errors.push(errorMessage); if (!options.continueOnError) { break; } } } return { success: errors.length === 0, files: results, errors, successCount: results.length, errorCount: errors.length }; } /** * Delete uploaded file * * @param filename - Filename to delete * @param userId - User ID (for security check) * @returns True if deleted successfully */ async deleteFile(filename, userId) { try { // Security check: ensure filename doesn't contain path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { throw new Upload_1.UploadError('Invalid filename', 'INVALID_FILENAME'); } const filePath = path.join(this.uploadDir, filename); // Check if file exists if (!await FileUtils_1.FileUtils.fileExists(filePath)) { return false; } // Additional security: check if filename matches expected pattern if (userId && !filename.includes(`_${userId}_`)) { throw new Upload_1.UploadError('Permission denied', 'PERMISSION_DENIED'); } await FileUtils_1.FileUtils.deleteFile(filePath); return true; } catch (error) { console.error('Delete file error:', error); return false; } } /** * Get file information * * @param filename - Filename * @returns File information or null if not found */ async getFileInfo(filename) { try { // Security check if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { return null; } const filePath = path.join(this.uploadDir, filename); const stats = await FileUtils_1.FileUtils.getFileStats(filePath); if (!stats) { return null; } // Parse filename to extract metadata (assuming secure filename format) const parts = filename.split('_'); let userId; if (parts.length >= 3) { userId = parts[1]; // Assuming format: prefix_userId_timestamp_random.ext } return { filename, originalName: filename, // Original name not stored in filename mimeType: FileUtils_1.FileUtils.getMimeTypeFromExtension(filename), size: stats.size, extension: path.extname(filename).slice(1), uploadedAt: stats.birthtime, userId, path: filePath, url: this.getFileUrl(filename) }; } catch (error) { return null; } } /** * Get storage information * * @param userId - User ID (optional, for user-specific stats) * @returns Storage information */ async getStorageInfo(userId) { let storageDir = this.uploadDir; // If userId provided, calculate only for that user if (userId) { // This is a simplified approach - in production, you might want // to organize files in user-specific directories const userFiles = await this.getUserFiles(userId); const totalUsed = userFiles.reduce((sum, file) => sum + file.size, 0); return { totalUsed, totalUsedFormatted: FileUtils_1.FileUtils.formatBytes(totalUsed), fileCount: userFiles.length, storageLimit: this.config.maxSize * 100, // Example: 100 files * maxSize usagePercentage: (totalUsed / (this.config.maxSize * 100)) * 100 }; } return await FileUtils_1.FileUtils.getStorageInfo(storageDir); } /** * Clean up old files * * @param options - Cleanup options * @returns Cleanup result */ async cleanupFiles(options = {}) { return await FileUtils_1.FileUtils.cleanupOldFiles(this.uploadDir, options); } /** * Generate thumbnails for image * * @param filename - Image filename * @param sizes - Thumbnail sizes * @returns Generated thumbnails */ async generateThumbnails(filename, sizes = [ { width: 150, height: 150, suffix: 'thumb' }, { width: 300, height: 300, suffix: 'small' }, { width: 800, height: 600, suffix: 'medium' } ]) { const filePath = path.join(this.uploadDir, filename); const thumbnailDir = path.join(this.uploadDir, 'thumbnails'); return await ImageOptimizer_1.ImageOptimizer.generateThumbnails(filePath, sizes, thumbnailDir); } /** * Convert image to WebP * * @param filename - Original filename * @param quality - WebP quality * @returns Conversion result */ async convertToWebP(filename, quality = 85) { const inputPath = path.join(this.uploadDir, filename); const webpFilename = filename.replace(/\.[^.]+$/, '.webp'); const outputPath = path.join(this.uploadDir, webpFilename); try { const result = await ImageOptimizer_1.ImageOptimizer.convertToWebP(inputPath, outputPath, quality); return { success: result.success, webpFilename, originalSize: result.originalSize, webpSize: result.webpSize, savings: result.savings }; } catch (error) { return { success: false, originalSize: 0, webpSize: 0, savings: 0 }; } } /** * Get user files * * @param userId - User ID * @returns Array of user files */ async getUserFiles(userId) { const files = []; try { const dirFiles = await fs.promises.readdir(this.uploadDir); for (const filename of dirFiles) { if (filename.includes(`_${userId}_`)) { const fileInfo = await this.getFileInfo(filename); if (fileInfo) { files.push(fileInfo); } } } } catch (error) { // Directory doesn't exist or access denied } return files; } /** * Check if file is an image * * @param mimeType - MIME type * @returns True if image */ isImageFile(mimeType) { return mimeType.startsWith('image/'); } /** * Get file URL * * @param filename - Filename * @returns File URL */ getFileUrl(filename) { // This should be configured based on your server setup // For example: return `/uploads/${filename}` or full URL return `/uploads/${filename}`; } /** * Update configuration * * @param config - New configuration */ updateConfig(config) { this.config = UploadConfig_1.UploadConfigManager.merge(this.config, config); this.uploadDir = UploadConfig_1.UploadConfigManager.getAbsoluteUploadDir(this.config); } /** * Get current configuration * * @returns Current configuration */ getConfig() { return { ...this.config }; } /** * Health check for upload system * * @returns Health status */ async healthCheck() { const issues = []; // Check if upload directory exists and is writable try { await FileUtils_1.FileUtils.ensureDirectory(this.uploadDir); // Test write permissions const testFile = path.join(this.uploadDir, '.write-test'); await fs.promises.writeFile(testFile, 'test'); await fs.promises.unlink(testFile); } catch (error) { issues.push('Upload directory is not writable'); } // Check storage usage const storageInfo = await this.getStorageInfo(); if (storageInfo.usagePercentage > 90) { issues.push('Storage usage is above 90%'); } // Validate configuration const configValidation = UploadConfig_1.UploadConfigManager.validate(this.config); if (!configValidation.isValid) { issues.push(...configValidation.errors); } return { healthy: issues.length === 0, issues, storageInfo }; } } exports.UploadHandler = UploadHandler; //# sourceMappingURL=UploadHandler.js.map