Merge: File Upload & Storage System (Agent 3) - resolved conflicts
This commit is contained in:
commit
b0dc9fca4d
19 changed files with 2918 additions and 196 deletions
236
components/upload/DocumentUploader.tsx
Normal file
236
components/upload/DocumentUploader.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* Document Uploader Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Upload interface for documents (PDF, DOC, etc.)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import type { FileCategory } from '../../lib/storage/types';
|
||||
import ProgressBar from './ProgressBar';
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
interface DocumentUploaderProps {
|
||||
category?: FileCategory;
|
||||
plantId?: string;
|
||||
farmId?: string;
|
||||
userId?: string;
|
||||
onUpload?: (file: UploadedDocument) => void;
|
||||
onError?: (error: string) => void;
|
||||
accept?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DocumentUploader({
|
||||
category = 'document',
|
||||
plantId,
|
||||
farmId,
|
||||
userId,
|
||||
onUpload,
|
||||
onError,
|
||||
accept = '.pdf,.doc,.docx',
|
||||
className = '',
|
||||
}: DocumentUploaderProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string>();
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedDocument | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setProgress(10);
|
||||
setError(undefined);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
if (plantId) formData.append('plantId', plantId);
|
||||
if (farmId) formData.append('farmId', farmId);
|
||||
if (userId) formData.append('userId', userId);
|
||||
|
||||
try {
|
||||
setProgress(30);
|
||||
|
||||
const response = await fetch('/api/upload/document', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
setProgress(80);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
setUploadedFile(data.file);
|
||||
onUpload?.(data.file);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||
setError(message);
|
||||
onError?.(message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setUploadedFile(null);
|
||||
setProgress(0);
|
||||
setError(undefined);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType === 'application/pdf') {
|
||||
return (
|
||||
<svg className="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zm-3 9.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zm3 3c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="w-8 h-8 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center p-4 border rounded-lg bg-gray-50">
|
||||
{getFileIcon(uploadedFile.mimeType)}
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{uploadedFile.originalName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(uploadedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<a
|
||||
href={uploadedFile.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
title="Download"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isUploading}
|
||||
className={`
|
||||
w-full border-2 border-dashed rounded-lg p-6 text-center
|
||||
transition-colors duration-200
|
||||
${isUploading
|
||||
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
|
||||
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="mx-auto h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<span className="text-green-600 font-medium">Click to upload</span>
|
||||
{' '}a document
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PDF, DOC, DOCX up to 10MB
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="mt-3">
|
||||
<ProgressBar progress={progress} />
|
||||
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentUploader;
|
||||
266
components/upload/ImageUploader.tsx
Normal file
266
components/upload/ImageUploader.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Image Uploader Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Drag & drop image upload with preview and progress
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import type { FileCategory } from '../../lib/storage/types';
|
||||
|
||||
interface UploadedFile {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ImageUploaderProps {
|
||||
category?: FileCategory;
|
||||
plantId?: string;
|
||||
farmId?: string;
|
||||
userId?: string;
|
||||
onUpload?: (file: UploadedFile) => void;
|
||||
onError?: (error: string) => void;
|
||||
maxFiles?: number;
|
||||
accept?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface UploadState {
|
||||
isUploading: boolean;
|
||||
progress: number;
|
||||
error?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export function ImageUploader({
|
||||
category = 'plant-photo',
|
||||
plantId,
|
||||
farmId,
|
||||
userId,
|
||||
onUpload,
|
||||
onError,
|
||||
maxFiles = 1,
|
||||
accept = 'image/*',
|
||||
className = '',
|
||||
}: ImageUploaderProps) {
|
||||
const [uploadState, setUploadState] = useState<UploadState>({
|
||||
isUploading: false,
|
||||
progress: 0,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setUploadState((prev) => ({
|
||||
...prev,
|
||||
preview: e.target?.result as string,
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Start upload
|
||||
setUploadState((prev) => ({
|
||||
...prev,
|
||||
isUploading: true,
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
if (plantId) formData.append('plantId', plantId);
|
||||
if (farmId) formData.append('farmId', farmId);
|
||||
if (userId) formData.append('userId', userId);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
progress: 100,
|
||||
preview: data.file.thumbnailUrl || data.file.url,
|
||||
});
|
||||
|
||||
onUpload?.(data.file);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadState((prev) => ({
|
||||
...prev,
|
||||
isUploading: false,
|
||||
error: message,
|
||||
}));
|
||||
onError?.(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).slice(0, maxFiles);
|
||||
if (files.length > 0) {
|
||||
await uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
[maxFiles]
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []).slice(0, maxFiles);
|
||||
if (files.length > 0) {
|
||||
await uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
[maxFiles]
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
progress: 0,
|
||||
preview: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadState.preview ? (
|
||||
<div className="relative rounded-lg overflow-hidden border-2 border-green-200">
|
||||
<img
|
||||
src={uploadState.preview}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
|
||||
title="Remove image"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
|
||||
transition-colors duration-200
|
||||
${isDragging
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50'
|
||||
}
|
||||
${uploadState.isUploading ? 'pointer-events-none opacity-50' : ''}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<span className="text-green-600 font-medium">Click to upload</span>
|
||||
{' '}or drag and drop
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PNG, JPG, GIF, WEBP up to 5MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState.isUploading && (
|
||||
<div className="mt-2">
|
||||
<div className="bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadState.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState.error && (
|
||||
<p className="mt-2 text-sm text-red-500 text-center">{uploadState.error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageUploader;
|
||||
213
components/upload/PhotoGallery.tsx
Normal file
213
components/upload/PhotoGallery.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Photo Gallery Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Displays a grid of plant photos with lightbox view
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
caption?: string;
|
||||
uploadedAt?: string;
|
||||
}
|
||||
|
||||
interface PhotoGalleryProps {
|
||||
photos: Photo[];
|
||||
onDelete?: (photoId: string) => void;
|
||||
editable?: boolean;
|
||||
columns?: 2 | 3 | 4;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhotoGallery({
|
||||
photos,
|
||||
onDelete,
|
||||
editable = false,
|
||||
columns = 3,
|
||||
className = '',
|
||||
}: PhotoGalleryProps) {
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (photoId: string) => {
|
||||
if (!onDelete) return;
|
||||
|
||||
setIsDeleting(photoId);
|
||||
try {
|
||||
await onDelete(photoId);
|
||||
} finally {
|
||||
setIsDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const gridCols = {
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-2 sm:grid-cols-3',
|
||||
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4',
|
||||
};
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className={`text-center py-8 ${className}`}>
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-500">No photos yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative group aspect-square rounded-lg overflow-hidden bg-gray-100"
|
||||
>
|
||||
<img
|
||||
src={photo.thumbnailUrl || photo.url}
|
||||
alt={photo.caption || 'Plant photo'}
|
||||
className="w-full h-full object-cover cursor-pointer transition-transform duration-200 group-hover:scale-105"
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
className="opacity-0 group-hover:opacity-100 bg-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-gray-100"
|
||||
title="View full size"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-700"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{editable && onDelete && (
|
||||
<button
|
||||
onClick={() => handleDelete(photo.id)}
|
||||
disabled={isDeleting === photo.id}
|
||||
className="opacity-0 group-hover:opacity-100 bg-red-500 text-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-red-600 disabled:opacity-50"
|
||||
title="Delete photo"
|
||||
>
|
||||
{isDeleting === photo.id ? (
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
{photo.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{selectedPhoto && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={selectedPhoto.url}
|
||||
alt={selectedPhoto.caption || 'Plant photo'}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{selectedPhoto.caption && (
|
||||
<div className="absolute bottom-4 left-4 right-4 text-center">
|
||||
<p className="text-white text-lg">{selectedPhoto.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PhotoGallery;
|
||||
63
components/upload/ProgressBar.tsx
Normal file
63
components/upload/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Progress Bar Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Animated progress bar for uploads
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
progress: number;
|
||||
showPercentage?: boolean;
|
||||
color?: 'green' | 'blue' | 'purple' | 'orange';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
green: 'bg-green-500',
|
||||
blue: 'bg-blue-500',
|
||||
purple: 'bg-purple-500',
|
||||
orange: 'bg-orange-500',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-1',
|
||||
md: 'h-2',
|
||||
lg: 'h-3',
|
||||
};
|
||||
|
||||
export function ProgressBar({
|
||||
progress,
|
||||
showPercentage = false,
|
||||
color = 'green',
|
||||
size = 'md',
|
||||
animated = true,
|
||||
className = '',
|
||||
}: ProgressBarProps) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={`bg-gray-200 rounded-full overflow-hidden ${sizeClasses[size]}`}>
|
||||
<div
|
||||
className={`
|
||||
${colorClasses[color]} ${sizeClasses[size]} rounded-full
|
||||
transition-all duration-300 ease-out
|
||||
${animated && clampedProgress < 100 ? 'animate-pulse' : ''}
|
||||
`}
|
||||
style={{ width: `${clampedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showPercentage && (
|
||||
<p className="text-xs text-gray-500 mt-1 text-right">
|
||||
{Math.round(clampedProgress)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProgressBar;
|
||||
11
components/upload/index.tsx
Normal file
11
components/upload/index.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Upload Components Index
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Export all upload-related components
|
||||
*/
|
||||
|
||||
export { ImageUploader } from './ImageUploader';
|
||||
export { PhotoGallery } from './PhotoGallery';
|
||||
export { DocumentUploader } from './DocumentUploader';
|
||||
export { ProgressBar } from './ProgressBar';
|
||||
87
lib/storage/config.ts
Normal file
87
lib/storage/config.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Storage Configuration for LocalGreenChain
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Supports multiple storage providers: AWS S3, Cloudflare R2, MinIO, or local filesystem
|
||||
*/
|
||||
|
||||
import { StorageConfig, StorageProvider } from './types';
|
||||
|
||||
function getStorageProvider(): StorageProvider {
|
||||
const provider = process.env.STORAGE_PROVIDER as StorageProvider;
|
||||
if (provider && ['s3', 'r2', 'minio', 'local'].includes(provider)) {
|
||||
return provider;
|
||||
}
|
||||
return 'local'; // Default to local storage for development
|
||||
}
|
||||
|
||||
export function getStorageConfig(): StorageConfig {
|
||||
const provider = getStorageProvider();
|
||||
|
||||
const baseConfig: StorageConfig = {
|
||||
provider,
|
||||
bucket: process.env.STORAGE_BUCKET || 'localgreenchain',
|
||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY,
|
||||
publicUrl: process.env.STORAGE_PUBLIC_URL,
|
||||
};
|
||||
|
||||
switch (provider) {
|
||||
case 's3':
|
||||
return {
|
||||
...baseConfig,
|
||||
endpoint: process.env.AWS_S3_ENDPOINT,
|
||||
};
|
||||
|
||||
case 'r2':
|
||||
return {
|
||||
...baseConfig,
|
||||
endpoint: process.env.CLOUDFLARE_R2_ENDPOINT ||
|
||||
`https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
region: 'auto',
|
||||
};
|
||||
|
||||
case 'minio':
|
||||
return {
|
||||
...baseConfig,
|
||||
endpoint: process.env.MINIO_ENDPOINT || 'http://localhost:9000',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
case 'local':
|
||||
default:
|
||||
return {
|
||||
...baseConfig,
|
||||
localPath: process.env.LOCAL_STORAGE_PATH || './uploads',
|
||||
publicUrl: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3001',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const storageConfig = getStorageConfig();
|
||||
|
||||
/**
|
||||
* Environment variable template for storage configuration:
|
||||
*
|
||||
* # Storage Provider (s3, r2, minio, local)
|
||||
* STORAGE_PROVIDER=local
|
||||
* STORAGE_BUCKET=localgreenchain
|
||||
* STORAGE_REGION=us-east-1
|
||||
* STORAGE_ACCESS_KEY_ID=your-access-key
|
||||
* STORAGE_SECRET_ACCESS_KEY=your-secret-key
|
||||
* STORAGE_PUBLIC_URL=https://cdn.yourdomain.com
|
||||
*
|
||||
* # For AWS S3
|
||||
* AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
*
|
||||
* # For Cloudflare R2
|
||||
* CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
* CLOUDFLARE_R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
|
||||
*
|
||||
* # For MinIO
|
||||
* MINIO_ENDPOINT=http://localhost:9000
|
||||
*
|
||||
* # For Local Storage
|
||||
* LOCAL_STORAGE_PATH=./uploads
|
||||
*/
|
||||
258
lib/storage/fileStore.ts
Normal file
258
lib/storage/fileStore.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* File Store for LocalGreenChain
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* In-memory file metadata store (to be replaced with Prisma when Agent 2 completes)
|
||||
*
|
||||
* Prisma Schema (for Agent 2 to integrate):
|
||||
*
|
||||
* model File {
|
||||
* id String @id @default(cuid())
|
||||
* filename String // Storage path/key
|
||||
* originalName String // Original uploaded filename
|
||||
* mimeType String
|
||||
* size Int // File size in bytes
|
||||
* category String // plant-photo, certificate, document, report, avatar
|
||||
* url String // Public URL
|
||||
* thumbnailUrl String? // Thumbnail URL (for images)
|
||||
* urls Json? // All size variants URLs
|
||||
* width Int? // Image width
|
||||
* height Int? // Image height
|
||||
* exifData Json? // EXIF metadata
|
||||
* uploadedById String? // User who uploaded
|
||||
* plantId String? // Associated plant
|
||||
* farmId String? // Associated farm
|
||||
* createdAt DateTime @default(now())
|
||||
* updatedAt DateTime @updatedAt
|
||||
* deletedAt DateTime? // Soft delete
|
||||
*
|
||||
* uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||
* plant Plant? @relation(fields: [plantId], references: [id])
|
||||
* farm VerticalFarm? @relation(fields: [farmId], references: [id])
|
||||
*
|
||||
* @@index([category])
|
||||
* @@index([plantId])
|
||||
* @@index([farmId])
|
||||
* @@index([uploadedById])
|
||||
* }
|
||||
*/
|
||||
|
||||
import { FileMetadata, FileCategory, ImageSize } from './types';
|
||||
|
||||
/**
|
||||
* In-memory file store
|
||||
* This is a temporary implementation until Prisma is set up by Agent 2
|
||||
*/
|
||||
class FileStore {
|
||||
private files: Map<string, FileMetadata> = new Map();
|
||||
|
||||
/**
|
||||
* Save file metadata
|
||||
*/
|
||||
async save(metadata: FileMetadata): Promise<FileMetadata> {
|
||||
this.files.set(metadata.id, {
|
||||
...metadata,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file by ID
|
||||
*/
|
||||
async getById(id: string): Promise<FileMetadata | null> {
|
||||
const file = this.files.get(id);
|
||||
if (!file || file.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file by filename/key
|
||||
*/
|
||||
async getByFilename(filename: string): Promise<FileMetadata | null> {
|
||||
for (const file of this.files.values()) {
|
||||
if (file.filename === filename && !file.deletedAt) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files by plant ID
|
||||
*/
|
||||
async getByPlantId(plantId: string): Promise<FileMetadata[]> {
|
||||
const results: FileMetadata[] = [];
|
||||
for (const file of this.files.values()) {
|
||||
if (file.plantId === plantId && !file.deletedAt) {
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files by farm ID
|
||||
*/
|
||||
async getByFarmId(farmId: string): Promise<FileMetadata[]> {
|
||||
const results: FileMetadata[] = [];
|
||||
for (const file of this.files.values()) {
|
||||
if (file.farmId === farmId && !file.deletedAt) {
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files by user ID
|
||||
*/
|
||||
async getByUserId(userId: string): Promise<FileMetadata[]> {
|
||||
const results: FileMetadata[] = [];
|
||||
for (const file of this.files.values()) {
|
||||
if (file.uploadedBy === userId && !file.deletedAt) {
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files by category
|
||||
*/
|
||||
async getByCategory(category: FileCategory): Promise<FileMetadata[]> {
|
||||
const results: FileMetadata[] = [];
|
||||
for (const file of this.files.values()) {
|
||||
if (file.category === category && !file.deletedAt) {
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file metadata
|
||||
*/
|
||||
async update(id: string, updates: Partial<FileMetadata>): Promise<FileMetadata | null> {
|
||||
const existing = this.files.get(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: FileMetadata = {
|
||||
...existing,
|
||||
...updates,
|
||||
id, // Ensure ID cannot be changed
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.files.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a file
|
||||
*/
|
||||
async softDelete(id: string): Promise<boolean> {
|
||||
const existing = this.files.get(id);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.files.set(id, {
|
||||
...existing,
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a file
|
||||
*/
|
||||
async hardDelete(id: string): Promise<boolean> {
|
||||
return this.files.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files with pagination
|
||||
*/
|
||||
async list(options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeDeleted?: boolean;
|
||||
} = {}): Promise<{ files: FileMetadata[]; total: number }> {
|
||||
const { limit = 50, offset = 0, includeDeleted = false } = options;
|
||||
|
||||
let results = Array.from(this.files.values());
|
||||
|
||||
if (!includeDeleted) {
|
||||
results = results.filter((f) => !f.deletedAt);
|
||||
}
|
||||
|
||||
// Sort by creation date descending
|
||||
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
const total = results.length;
|
||||
const files = results.slice(offset, offset + limit);
|
||||
|
||||
return { files, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
async getStats(): Promise<{
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
byCategory: Record<FileCategory, { count: number; size: number }>;
|
||||
}> {
|
||||
const stats: {
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
byCategory: Record<FileCategory, { count: number; size: number }>;
|
||||
} = {
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
byCategory: {
|
||||
'plant-photo': { count: 0, size: 0 },
|
||||
'certificate': { count: 0, size: 0 },
|
||||
'document': { count: 0, size: 0 },
|
||||
'report': { count: 0, size: 0 },
|
||||
'avatar': { count: 0, size: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
for (const file of this.files.values()) {
|
||||
if (!file.deletedAt) {
|
||||
stats.totalFiles++;
|
||||
stats.totalSize += file.size;
|
||||
stats.byCategory[file.category].count++;
|
||||
stats.byCategory[file.category].size += file.size;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all files (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.files.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let fileStoreInstance: FileStore | null = null;
|
||||
|
||||
export function getFileStore(): FileStore {
|
||||
if (!fileStoreInstance) {
|
||||
fileStoreInstance = new FileStore();
|
||||
}
|
||||
return fileStoreInstance;
|
||||
}
|
||||
|
||||
export { FileStore };
|
||||
269
lib/storage/imageProcessor.ts
Normal file
269
lib/storage/imageProcessor.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Image Processing Pipeline for LocalGreenChain
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Handles image optimization, thumbnail generation, and EXIF extraction
|
||||
*/
|
||||
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
ImageSize,
|
||||
ThumbnailConfig,
|
||||
THUMBNAIL_CONFIGS,
|
||||
ExifData,
|
||||
ImageProcessingOptions,
|
||||
} from './types';
|
||||
|
||||
export interface ImageMetadata {
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
space?: string;
|
||||
channels?: number;
|
||||
hasAlpha?: boolean;
|
||||
orientation?: number;
|
||||
}
|
||||
|
||||
export class ImageProcessor {
|
||||
private defaultQuality = 85;
|
||||
|
||||
/**
|
||||
* Get image metadata
|
||||
*/
|
||||
async getMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
|
||||
return {
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
format: metadata.format || 'unknown',
|
||||
space: metadata.space,
|
||||
channels: metadata.channels,
|
||||
hasAlpha: metadata.hasAlpha,
|
||||
orientation: metadata.orientation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract EXIF data from image
|
||||
*/
|
||||
async extractExif(buffer: Buffer): Promise<ExifData | undefined> {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
|
||||
if (!metadata.exif) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse basic EXIF data
|
||||
const exifData: ExifData = {};
|
||||
|
||||
// Sharp provides some EXIF data directly
|
||||
if (metadata.orientation) {
|
||||
exifData.orientation = metadata.orientation;
|
||||
}
|
||||
|
||||
// For more detailed EXIF parsing, we would need an EXIF library
|
||||
// For now, return basic data
|
||||
return Object.keys(exifData).length > 0 ? exifData : undefined;
|
||||
} catch (error) {
|
||||
console.warn('Error extracting EXIF data:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize an image for web
|
||||
*/
|
||||
async optimize(
|
||||
buffer: Buffer,
|
||||
options: ImageProcessingOptions = {}
|
||||
): Promise<Buffer> {
|
||||
const {
|
||||
maxWidth = 2048,
|
||||
maxHeight = 2048,
|
||||
quality = this.defaultQuality,
|
||||
convertToWebP = true,
|
||||
} = options;
|
||||
|
||||
let pipeline = sharp(buffer)
|
||||
.rotate() // Auto-rotate based on EXIF orientation
|
||||
.resize(maxWidth, maxHeight, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
|
||||
if (convertToWebP) {
|
||||
pipeline = pipeline.webp({ quality });
|
||||
} else {
|
||||
// Optimize in original format
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
switch (metadata.format) {
|
||||
case 'jpeg':
|
||||
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
|
||||
break;
|
||||
case 'png':
|
||||
pipeline = pipeline.png({ compressionLevel: 9 });
|
||||
break;
|
||||
case 'gif':
|
||||
pipeline = pipeline.gif();
|
||||
break;
|
||||
default:
|
||||
pipeline = pipeline.webp({ quality });
|
||||
}
|
||||
}
|
||||
|
||||
return pipeline.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all thumbnail sizes
|
||||
*/
|
||||
async generateThumbnails(
|
||||
buffer: Buffer,
|
||||
sizes: ImageSize[] = ['thumbnail', 'small', 'medium', 'large']
|
||||
): Promise<Record<string, Buffer>> {
|
||||
const thumbnails: Record<string, Buffer> = {};
|
||||
|
||||
await Promise.all(
|
||||
sizes.map(async (size) => {
|
||||
if (size === 'original') return;
|
||||
|
||||
const config = THUMBNAIL_CONFIGS[size];
|
||||
thumbnails[size] = await this.generateThumbnail(buffer, config);
|
||||
})
|
||||
);
|
||||
|
||||
return thumbnails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single thumbnail
|
||||
*/
|
||||
async generateThumbnail(buffer: Buffer, config: ThumbnailConfig): Promise<Buffer> {
|
||||
return sharp(buffer)
|
||||
.rotate() // Auto-rotate based on EXIF orientation
|
||||
.resize(config.width, config.height, {
|
||||
fit: config.fit,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image to WebP format
|
||||
*/
|
||||
async toWebP(buffer: Buffer, quality = 85): Promise<Buffer> {
|
||||
return sharp(buffer)
|
||||
.rotate()
|
||||
.webp({ quality })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop image to specific dimensions
|
||||
*/
|
||||
async crop(
|
||||
buffer: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
options: { left?: number; top?: number } = {}
|
||||
): Promise<Buffer> {
|
||||
const { left = 0, top = 0 } = options;
|
||||
|
||||
return sharp(buffer)
|
||||
.extract({ left, top, width, height })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart crop using attention/entropy detection
|
||||
*/
|
||||
async smartCrop(buffer: Buffer, width: number, height: number): Promise<Buffer> {
|
||||
return sharp(buffer)
|
||||
.resize(width, height, {
|
||||
fit: 'cover',
|
||||
position: 'attention', // Focus on the most "interesting" part
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a watermark to an image
|
||||
*/
|
||||
async addWatermark(
|
||||
buffer: Buffer,
|
||||
watermarkBuffer: Buffer,
|
||||
options: {
|
||||
position?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
opacity?: number;
|
||||
} = {}
|
||||
): Promise<Buffer> {
|
||||
const { position = 'bottom-right', opacity = 0.5 } = options;
|
||||
|
||||
const image = sharp(buffer);
|
||||
const metadata = await image.metadata();
|
||||
const watermark = await sharp(watermarkBuffer)
|
||||
.resize(Math.round((metadata.width || 500) * 0.2), null, {
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.ensureAlpha()
|
||||
.toBuffer();
|
||||
|
||||
const watermarkMeta = await sharp(watermark).metadata();
|
||||
|
||||
let gravity: sharp.Gravity;
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
gravity = 'northwest';
|
||||
break;
|
||||
case 'top-right':
|
||||
gravity = 'northeast';
|
||||
break;
|
||||
case 'bottom-left':
|
||||
gravity = 'southwest';
|
||||
break;
|
||||
case 'bottom-right':
|
||||
gravity = 'southeast';
|
||||
break;
|
||||
case 'center':
|
||||
default:
|
||||
gravity = 'center';
|
||||
}
|
||||
|
||||
return image
|
||||
.composite([
|
||||
{
|
||||
input: watermark,
|
||||
gravity,
|
||||
blend: 'over',
|
||||
},
|
||||
])
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a blur placeholder for progressive loading
|
||||
*/
|
||||
async generateBlurPlaceholder(buffer: Buffer, size = 10): Promise<string> {
|
||||
const blurredBuffer = await sharp(buffer)
|
||||
.resize(size, size, { fit: 'inside' })
|
||||
.webp({ quality: 20 })
|
||||
.toBuffer();
|
||||
|
||||
return `data:image/webp;base64,${blurredBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that buffer is a valid image
|
||||
*/
|
||||
async isValidImage(buffer: Buffer): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
return !!(metadata.width && metadata.height);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lib/storage/index.ts
Normal file
34
lib/storage/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Storage Module for LocalGreenChain
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Main entry point for file storage functionality
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Configuration
|
||||
export { getStorageConfig, storageConfig } from './config';
|
||||
|
||||
// Services
|
||||
export { getUploadService, UploadService } from './uploadService';
|
||||
export { ImageProcessor } from './imageProcessor';
|
||||
export { getFileStore, FileStore } from './fileStore';
|
||||
|
||||
// Providers
|
||||
export { S3StorageProvider } from './providers/s3';
|
||||
export { LocalStorageProvider } from './providers/local';
|
||||
|
||||
// Re-export commonly used types for convenience
|
||||
export type {
|
||||
FileMetadata,
|
||||
FileCategory,
|
||||
UploadOptions,
|
||||
UploadResult,
|
||||
PresignedUrlRequest,
|
||||
PresignedUrlResponse,
|
||||
ImageSize,
|
||||
StorageProvider,
|
||||
StorageConfig,
|
||||
} from './types';
|
||||
131
lib/storage/providers/local.ts
Normal file
131
lib/storage/providers/local.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
115
lib/storage/providers/s3.ts
Normal file
115
lib/storage/providers/s3.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* S3 Storage Provider for LocalGreenChain
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Compatible with AWS S3, Cloudflare R2, and MinIO
|
||||
*/
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { StorageConfig, StorageProviderInterface } from '../types';
|
||||
|
||||
export class S3StorageProvider implements StorageProviderInterface {
|
||||
private client: S3Client;
|
||||
private bucket: string;
|
||||
private publicUrl?: string;
|
||||
|
||||
constructor(config: StorageConfig) {
|
||||
this.bucket = config.bucket;
|
||||
this.publicUrl = config.publicUrl;
|
||||
|
||||
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
|
||||
region: config.region || 'us-east-1',
|
||||
};
|
||||
|
||||
// Add endpoint for R2/MinIO
|
||||
if (config.endpoint) {
|
||||
clientConfig.endpoint = config.endpoint;
|
||||
clientConfig.forcePathStyle = config.provider === 'minio';
|
||||
}
|
||||
|
||||
// Add credentials if provided
|
||||
if (config.accessKeyId && config.secretAccessKey) {
|
||||
clientConfig.credentials = {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
};
|
||||
}
|
||||
|
||||
this.client = new S3Client(clientConfig);
|
||||
}
|
||||
|
||||
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
CacheControl: 'public, max-age=31536000', // 1 year cache
|
||||
});
|
||||
|
||||
await this.client.send(command);
|
||||
return this.getPublicUrl(key);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
try {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
await this.client.send(command);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting file from S3:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(this.client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async getPresignedUploadUrl(key: string, contentType: string, expiresIn = 3600): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
return getSignedUrl(this.client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
await this.client.send(command);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getPublicUrl(key: string): string {
|
||||
if (this.publicUrl) {
|
||||
return `${this.publicUrl}/${key}`;
|
||||
}
|
||||
return `https://${this.bucket}.s3.amazonaws.com/${key}`;
|
||||
}
|
||||
}
|
||||
133
lib/storage/types.ts
Normal file
133
lib/storage/types.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* File Storage Types for LocalGreenChain
|
||||
* Agent 3: File Upload & Storage System
|
||||
*/
|
||||
|
||||
export type StorageProvider = 's3' | 'r2' | 'minio' | 'local';
|
||||
|
||||
export type FileCategory = 'plant-photo' | 'certificate' | 'document' | 'report' | 'avatar';
|
||||
|
||||
export type ImageSize = 'thumbnail' | 'small' | 'medium' | 'large' | 'original';
|
||||
|
||||
export interface StorageConfig {
|
||||
provider: StorageProvider;
|
||||
bucket: string;
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
publicUrl?: string;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
export interface FileMetadata {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
category: FileCategory;
|
||||
uploadedBy?: string;
|
||||
plantId?: string;
|
||||
farmId?: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
urls?: Record<ImageSize, string>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
exifData?: ExifData;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ExifData {
|
||||
make?: string;
|
||||
model?: string;
|
||||
dateTaken?: Date;
|
||||
gpsLatitude?: number;
|
||||
gpsLongitude?: number;
|
||||
orientation?: number;
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
category: FileCategory;
|
||||
plantId?: string;
|
||||
farmId?: string;
|
||||
userId?: string;
|
||||
generateThumbnails?: boolean;
|
||||
maxSizeBytes?: number;
|
||||
allowedMimeTypes?: string[];
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
file?: FileMetadata;
|
||||
error?: string;
|
||||
presignedUrl?: string;
|
||||
}
|
||||
|
||||
export interface PresignedUrlRequest {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
category: FileCategory;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export interface PresignedUrlResponse {
|
||||
uploadUrl: string;
|
||||
fileKey: string;
|
||||
publicUrl: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface ImageProcessingOptions {
|
||||
generateThumbnails?: boolean;
|
||||
convertToWebP?: boolean;
|
||||
extractExif?: boolean;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export interface ThumbnailConfig {
|
||||
size: ImageSize;
|
||||
width: number;
|
||||
height: number;
|
||||
fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
|
||||
}
|
||||
|
||||
export const THUMBNAIL_CONFIGS: Record<ImageSize, ThumbnailConfig> = {
|
||||
thumbnail: { size: 'thumbnail', width: 150, height: 150, fit: 'cover' },
|
||||
small: { size: 'small', width: 300, height: 300, fit: 'inside' },
|
||||
medium: { size: 'medium', width: 600, height: 600, fit: 'inside' },
|
||||
large: { size: 'large', width: 1200, height: 1200, fit: 'inside' },
|
||||
original: { size: 'original', width: 0, height: 0, fit: 'inside' },
|
||||
};
|
||||
|
||||
export const ALLOWED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
];
|
||||
|
||||
export const ALLOWED_DOCUMENT_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
|
||||
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
export const DEFAULT_MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
export interface StorageProviderInterface {
|
||||
upload(key: string, buffer: Buffer, contentType: string): Promise<string>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
getSignedUrl(key: string, expiresIn?: number): Promise<string>;
|
||||
getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string>;
|
||||
exists(key: string): Promise<boolean>;
|
||||
getPublicUrl(key: string): string;
|
||||
}
|
||||
270
lib/storage/uploadService.ts
Normal file
270
lib/storage/uploadService.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* 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 };
|
||||
|
|
@ -31,6 +31,8 @@
|
|||
"validate": "bun run type-check && bun run lint && bun run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.937.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.937.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
|
|
@ -40,6 +42,7 @@
|
|||
"classnames": "^2.3.1",
|
||||
"drupal-jsonapi-params": "^1.2.2",
|
||||
"html-react-parser": "^1.2.7",
|
||||
"multer": "^2.0.2",
|
||||
"next": "^12.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-drupal": "^1.6.0",
|
||||
|
|
@ -47,6 +50,7 @@
|
|||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-hook-form": "^7.8.6",
|
||||
"sharp": "^0.34.5",
|
||||
"socks-proxy-agent": "^8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -55,8 +59,10 @@
|
|||
"@commitlint/config-conventional": "^18.4.3",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"cypress": "^13.6.0",
|
||||
"eslint-config-next": "^12.0.10",
|
||||
|
|
|
|||
99
pages/api/upload/[fileId].ts
Normal file
99
pages/api/upload/[fileId].ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* File Management API Endpoint
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* GET /api/upload/[fileId] - Get file info or signed URL
|
||||
* DELETE /api/upload/[fileId] - Delete a file
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getUploadService } from '../../../lib/storage';
|
||||
|
||||
interface FileResponse {
|
||||
success: boolean;
|
||||
signedUrl?: string;
|
||||
exists?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DeleteResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<FileResponse | DeleteResponse>
|
||||
) {
|
||||
const { fileId } = req.query;
|
||||
|
||||
if (!fileId || typeof fileId !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'File ID is required' });
|
||||
}
|
||||
|
||||
// Decode the file key (it may be URL encoded)
|
||||
const fileKey = decodeURIComponent(fileId);
|
||||
|
||||
const uploadService = getUploadService();
|
||||
|
||||
switch (req.method) {
|
||||
case 'GET': {
|
||||
try {
|
||||
// Check if file exists
|
||||
const exists = await uploadService.exists(fileKey);
|
||||
|
||||
if (!exists) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
exists: false,
|
||||
error: 'File not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Get expiration time from query params (default 1 hour)
|
||||
const expiresIn = parseInt(req.query.expiresIn as string) || 3600;
|
||||
|
||||
// Get signed URL for private file access
|
||||
const signedUrl = await uploadService.getSignedUrl(fileKey, expiresIn);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
exists: true,
|
||||
signedUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get file error:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELETE': {
|
||||
try {
|
||||
const deleted = await uploadService.delete(fileKey);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'File not found or could not be deleted',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete file error:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
160
pages/api/upload/document.ts
Normal file
160
pages/api/upload/document.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Document Upload API Endpoint
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* POST /api/upload/document - Upload a document file (PDF, DOC, etc.)
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getUploadService } from '../../../lib/storage';
|
||||
import type { FileCategory } 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;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
originalName: 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 = 'document';
|
||||
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) {
|
||||
filename = filenameMatch[1];
|
||||
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
|
||||
if (contentTypeMatch) {
|
||||
mimeType = contentTypeMatch[1].trim();
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
|
||||
// Validate document type
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(mimeType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid file type. Only PDF, DOC, DOCX, and images are allowed.',
|
||||
});
|
||||
}
|
||||
|
||||
const category = (fields.category as FileCategory) || 'document';
|
||||
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: false, // No thumbnails for documents
|
||||
});
|
||||
|
||||
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,
|
||||
size: result.file.size,
|
||||
mimeType: result.file.mimeType,
|
||||
originalName: result.file.originalName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Document upload error:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
153
pages/api/upload/image.ts
Normal file
153
pages/api/upload/image.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
92
pages/api/upload/presigned.ts
Normal file
92
pages/api/upload/presigned.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Presigned URL API Endpoint
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* POST /api/upload/presigned - Get a presigned URL for direct upload
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getUploadService } from '../../../lib/storage';
|
||||
import type { FileCategory, PresignedUrlResponse } from '../../../lib/storage/types';
|
||||
|
||||
interface RequestBody {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
category: FileCategory;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
interface PresignedResponse {
|
||||
success: boolean;
|
||||
data?: PresignedUrlResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<PresignedResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { filename, contentType, category, expiresIn } = req.body as RequestBody;
|
||||
|
||||
// Validate required fields
|
||||
if (!filename || !contentType || !category) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: filename, contentType, category',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate category
|
||||
const validCategories: FileCategory[] = ['plant-photo', 'certificate', 'document', 'report', 'avatar'];
|
||||
if (!validCategories.includes(category)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid category. Must be one of: ${validCategories.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
const allowedContentTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
|
||||
if (!allowedContentTypes.includes(contentType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid content type. Must be one of: ${allowedContentTypes.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const uploadService = getUploadService();
|
||||
const presignedUrl = await uploadService.getPresignedUploadUrl({
|
||||
filename,
|
||||
contentType,
|
||||
category,
|
||||
expiresIn: expiresIn || 3600, // Default 1 hour
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: presignedUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Presigned URL error:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue