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
131 lines
3.6 KiB
TypeScript
131 lines
3.6 KiB
TypeScript
/**
|
|
* Local Storage Provider for LocalGreenChain
|
|
* Agent 3: File Upload & Storage System
|
|
*
|
|
* Filesystem-based storage for development and testing
|
|
*/
|
|
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import { StorageConfig, StorageProviderInterface } from '../types';
|
|
|
|
export class LocalStorageProvider implements StorageProviderInterface {
|
|
private basePath: string;
|
|
private publicUrl: string;
|
|
|
|
constructor(config: StorageConfig) {
|
|
this.basePath = config.localPath || './uploads';
|
|
this.publicUrl = config.publicUrl || 'http://localhost:3001';
|
|
}
|
|
|
|
private getFilePath(key: string): string {
|
|
return path.join(this.basePath, key);
|
|
}
|
|
|
|
private async ensureDirectory(filePath: string): Promise<void> {
|
|
const dir = path.dirname(filePath);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
|
|
const filePath = this.getFilePath(key);
|
|
await this.ensureDirectory(filePath);
|
|
|
|
await fs.writeFile(filePath, buffer);
|
|
|
|
// Write metadata file
|
|
const metadataPath = `${filePath}.meta.json`;
|
|
await fs.writeFile(
|
|
metadataPath,
|
|
JSON.stringify({
|
|
contentType,
|
|
size: buffer.length,
|
|
uploadedAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
return this.getPublicUrl(key);
|
|
}
|
|
|
|
async delete(key: string): Promise<boolean> {
|
|
try {
|
|
const filePath = this.getFilePath(key);
|
|
await fs.unlink(filePath);
|
|
|
|
// Also delete metadata file if exists
|
|
try {
|
|
await fs.unlink(`${filePath}.meta.json`);
|
|
} catch {
|
|
// Metadata file may not exist
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error deleting local file:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
|
// For local storage, we generate a signed URL using HMAC
|
|
const expires = Date.now() + expiresIn * 1000;
|
|
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
|
|
const signature = crypto
|
|
.createHmac('sha256', secret)
|
|
.update(`${key}:${expires}`)
|
|
.digest('hex')
|
|
.substring(0, 16);
|
|
|
|
return `${this.publicUrl}/api/upload/${encodeURIComponent(key)}?expires=${expires}&sig=${signature}`;
|
|
}
|
|
|
|
async getPresignedUploadUrl(key: string, contentType: string, expiresIn = 3600): Promise<string> {
|
|
// For local storage, return an API endpoint for upload
|
|
const expires = Date.now() + expiresIn * 1000;
|
|
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
|
|
const signature = crypto
|
|
.createHmac('sha256', secret)
|
|
.update(`upload:${key}:${contentType}:${expires}`)
|
|
.digest('hex')
|
|
.substring(0, 16);
|
|
|
|
return `${this.publicUrl}/api/upload/presigned?key=${encodeURIComponent(key)}&contentType=${encodeURIComponent(contentType)}&expires=${expires}&sig=${signature}`;
|
|
}
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(this.getFilePath(key));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getPublicUrl(key: string): string {
|
|
return `${this.publicUrl}/uploads/${key}`;
|
|
}
|
|
|
|
/**
|
|
* Verify a signed URL signature
|
|
*/
|
|
static verifySignature(
|
|
key: string,
|
|
expires: number,
|
|
signature: string,
|
|
prefix = ''
|
|
): boolean {
|
|
if (Date.now() > expires) {
|
|
return false;
|
|
}
|
|
|
|
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
|
|
const expectedSignature = crypto
|
|
.createHmac('sha256', secret)
|
|
.update(`${prefix}${key}:${expires}`)
|
|
.digest('hex')
|
|
.substring(0, 16);
|
|
|
|
return signature === expectedSignature;
|
|
}
|
|
}
|