Implements cloud-based file storage for plant photos, documents, and certificates: Storage Layer: - Multi-provider support (AWS S3, Cloudflare R2, MinIO, local filesystem) - S3-compatible provider with presigned URL generation - Local storage provider for development with signed URL verification - Configurable via environment variables Image Processing: - Automatic thumbnail generation (150x150, 300x300, 600x600, 1200x1200) - WebP conversion for optimized file sizes - EXIF data extraction for image metadata - Image optimization with Sharp API Endpoints: - POST /api/upload/image - Upload images with automatic processing - POST /api/upload/document - Upload documents (PDF, DOC, DOCX) - POST /api/upload/presigned - Get presigned URLs for direct uploads - GET/DELETE /api/upload/[fileId] - File management UI Components: - ImageUploader - Drag & drop image upload with preview - PhotoGallery - Grid gallery with lightbox view - DocumentUploader - Document upload with file type icons - ProgressBar - Animated upload progress indicator Database: - FileStore service with in-memory storage (Prisma schema ready for Agent 2) - File metadata tracking with soft delete support - Category-based file organization
270 lines
7.7 KiB
TypeScript
270 lines
7.7 KiB
TypeScript
/**
|
|
* 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<UploadResult> {
|
|
// 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<Record<ImageSize, string>> = {};
|
|
|
|
// 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<ImageSize, string>,
|
|
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<PresignedUrlResponse> {
|
|
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<boolean> {
|
|
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<string> {
|
|
return this.provider.getSignedUrl(fileKey, expiresIn);
|
|
}
|
|
|
|
/**
|
|
* Check if a file exists
|
|
*/
|
|
async exists(fileKey: string): Promise<boolean> {
|
|
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 };
|