/** * Upload Service for LocalGreenChain * Agent 3: File Upload & Storage System * * Core upload functionality with validation and processing */ import { v4 as uuidv4 } from 'crypto'; import { FileMetadata, FileCategory, UploadOptions, UploadResult, PresignedUrlRequest, PresignedUrlResponse, StorageProviderInterface, ALLOWED_IMAGE_TYPES, ALLOWED_DOCUMENT_TYPES, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_IMAGE_SIZE, ImageSize, } from './types'; import { getStorageConfig, storageConfig } from './config'; import { S3StorageProvider } from './providers/s3'; import { LocalStorageProvider } from './providers/local'; import { ImageProcessor } from './imageProcessor'; // Generate a UUID v4 function generateId(): string { const bytes = require('crypto').randomBytes(16); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; return bytes.toString('hex').replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5'); } class UploadService { private provider: StorageProviderInterface; private imageProcessor: ImageProcessor; constructor() { const config = getStorageConfig(); switch (config.provider) { case 's3': case 'r2': case 'minio': this.provider = new S3StorageProvider(config); break; case 'local': default: this.provider = new LocalStorageProvider(config); break; } this.imageProcessor = new ImageProcessor(); } /** * Generate a unique file key with path */ private generateFileKey(filename: string, category: FileCategory): string { const id = generateId(); const ext = filename.split('.').pop()?.toLowerCase() || 'bin'; const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); return `${category}/${year}/${month}/${id}.${ext}`; } /** * Validate file type and size */ private validateFile( buffer: Buffer, mimeType: string, options: UploadOptions ): { valid: boolean; error?: string } { const isImage = ALLOWED_IMAGE_TYPES.includes(mimeType); const isDocument = ALLOWED_DOCUMENT_TYPES.includes(mimeType); // Check allowed types based on category if (options.category === 'plant-photo' || options.category === 'avatar') { if (!isImage) { return { valid: false, error: 'Only image files are allowed for this category' }; } } if (options.category === 'document' || options.category === 'certificate') { if (!isDocument && !isImage) { return { valid: false, error: 'Only documents and images are allowed for this category' }; } } // Check custom allowed types if (options.allowedMimeTypes && options.allowedMimeTypes.length > 0) { if (!options.allowedMimeTypes.includes(mimeType)) { return { valid: false, error: `File type ${mimeType} is not allowed` }; } } // Check file size const maxSize = options.maxSizeBytes || (isImage ? DEFAULT_MAX_IMAGE_SIZE : DEFAULT_MAX_FILE_SIZE); if (buffer.length > maxSize) { const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1); return { valid: false, error: `File size exceeds maximum of ${maxSizeMB}MB` }; } return { valid: true }; } /** * Upload a file with processing */ async upload( buffer: Buffer, originalName: string, mimeType: string, options: UploadOptions ): Promise { // Validate file const validation = this.validateFile(buffer, mimeType, options); if (!validation.valid) { return { success: false, error: validation.error }; } const isImage = ALLOWED_IMAGE_TYPES.includes(mimeType); const fileKey = this.generateFileKey(originalName, options.category); const fileId = generateId(); try { let processedBuffer = buffer; let width: number | undefined; let height: number | undefined; let exifData: FileMetadata['exifData']; const urls: Partial> = {}; // Process images if (isImage && options.generateThumbnails !== false) { // Get image metadata const metadata = await this.imageProcessor.getMetadata(buffer); width = metadata.width; height = metadata.height; // Extract EXIF data exifData = await this.imageProcessor.extractExif(buffer); // Optimize original image const optimized = await this.imageProcessor.optimize(buffer); processedBuffer = optimized; // Generate thumbnails const thumbnails = await this.imageProcessor.generateThumbnails(buffer); // Upload thumbnails for (const [size, thumbBuffer] of Object.entries(thumbnails)) { const thumbKey = fileKey.replace(/\.[^.]+$/, `-${size}.webp`); urls[size as ImageSize] = await this.provider.upload(thumbKey, thumbBuffer, 'image/webp'); } } // Upload main file const finalMimeType = isImage ? 'image/webp' : mimeType; const finalKey = isImage ? fileKey.replace(/\.[^.]+$/, '.webp') : fileKey; const mainUrl = await this.provider.upload(finalKey, processedBuffer, finalMimeType); urls.original = mainUrl; // Create file metadata const fileMetadata: FileMetadata = { id: fileId, filename: finalKey, originalName, mimeType: finalMimeType, size: processedBuffer.length, category: options.category, uploadedBy: options.userId, plantId: options.plantId, farmId: options.farmId, url: mainUrl, thumbnailUrl: urls.thumbnail, urls: urls as Record, width, height, exifData, createdAt: new Date(), updatedAt: new Date(), }; return { success: true, file: fileMetadata }; } catch (error) { console.error('Upload error:', error); return { success: false, error: error instanceof Error ? error.message : 'Upload failed', }; } } /** * Generate a presigned URL for direct upload */ async getPresignedUploadUrl(request: PresignedUrlRequest): Promise { const fileKey = this.generateFileKey(request.filename, request.category); const expiresIn = request.expiresIn || 3600; // 1 hour default const uploadUrl = await this.provider.getPresignedUploadUrl( fileKey, request.contentType, expiresIn ); return { uploadUrl, fileKey, publicUrl: this.provider.getPublicUrl(fileKey), expiresAt: new Date(Date.now() + expiresIn * 1000), }; } /** * Delete a file and its thumbnails */ async delete(fileKey: string): Promise { try { // Delete main file await this.provider.delete(fileKey); // Delete thumbnails if they exist const sizes: ImageSize[] = ['thumbnail', 'small', 'medium', 'large']; for (const size of sizes) { const thumbKey = fileKey.replace(/\.[^.]+$/, `-${size}.webp`); await this.provider.delete(thumbKey); } return true; } catch (error) { console.error('Delete error:', error); return false; } } /** * Get a signed URL for private file access */ async getSignedUrl(fileKey: string, expiresIn = 3600): Promise { return this.provider.getSignedUrl(fileKey, expiresIn); } /** * Check if a file exists */ async exists(fileKey: string): Promise { return this.provider.exists(fileKey); } } // Singleton instance let uploadServiceInstance: UploadService | null = null; export function getUploadService(): UploadService { if (!uploadServiceInstance) { uploadServiceInstance = new UploadService(); } return uploadServiceInstance; } export { UploadService };