@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
JavaScript
"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