localgreenchain/pages/api/upload/image.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

153 lines
4.4 KiB
TypeScript

/**
* Image Upload API Endpoint
* Agent 3: File Upload & Storage System
*
* POST /api/upload/image - Upload an image file
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
import type { FileCategory, UploadResult } from '../../../lib/storage/types';
// Disable body parser to handle multipart/form-data
export const config = {
api: {
bodyParser: false,
},
};
interface UploadResponse {
success: boolean;
file?: {
id: string;
url: string;
thumbnailUrl?: string;
urls?: Record<string, string>;
width?: number;
height?: number;
size: number;
mimeType: string;
};
error?: string;
}
async function parseMultipartForm(
req: NextApiRequest
): Promise<{ buffer: Buffer; filename: string; mimeType: string; fields: Record<string, string> }> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let filename = 'upload';
let mimeType = 'application/octet-stream';
const fields: Record<string, string> = {};
const contentType = req.headers['content-type'] || '';
const boundary = contentType.split('boundary=')[1];
if (!boundary) {
reject(new Error('Missing multipart boundary'));
return;
}
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
const content = buffer.toString('binary');
const parts = content.split(`--${boundary}`);
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
if (filenameMatch) {
// This is a file
filename = filenameMatch[1];
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (contentTypeMatch) {
mimeType = contentTypeMatch[1].trim();
}
// Extract file content (after double CRLF)
const contentStart = part.indexOf('\r\n\r\n') + 4;
const contentEnd = part.lastIndexOf('\r\n');
if (contentStart > 4 && contentEnd > contentStart) {
const fileContent = part.slice(contentStart, contentEnd);
const fileBuffer = Buffer.from(fileContent, 'binary');
resolve({ buffer: fileBuffer, filename, mimeType, fields });
return;
}
} else if (nameMatch) {
// This is a regular field
const fieldName = nameMatch[1];
const contentStart = part.indexOf('\r\n\r\n') + 4;
const contentEnd = part.lastIndexOf('\r\n');
if (contentStart > 4 && contentEnd > contentStart) {
fields[fieldName] = part.slice(contentStart, contentEnd).trim();
}
}
}
}
reject(new Error('No file found in request'));
});
req.on('error', reject);
});
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<UploadResponse>
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const { buffer, filename, mimeType, fields } = await parseMultipartForm(req);
const category = (fields.category as FileCategory) || 'plant-photo';
const plantId = fields.plantId;
const farmId = fields.farmId;
const userId = fields.userId;
const uploadService = getUploadService();
const result = await uploadService.upload(buffer, filename, mimeType, {
category,
plantId,
farmId,
userId,
generateThumbnails: true,
});
if (!result.success || !result.file) {
return res.status(400).json({
success: false,
error: result.error || 'Upload failed',
});
}
return res.status(200).json({
success: true,
file: {
id: result.file.id,
url: result.file.url,
thumbnailUrl: result.file.thumbnailUrl,
urls: result.file.urls,
width: result.file.width,
height: result.file.height,
size: result.file.size,
mimeType: result.file.mimeType,
},
});
} catch (error) {
console.error('Upload error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}