localgreenchain/lib/storage/uploadService.ts
Claude d74128d3cd
Add Agent 3: File Upload & Storage System
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
2025-11-23 03:51:31 +00:00

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 };