279 lines
7.9 KiB
TypeScript
279 lines
7.9 KiB
TypeScript
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"
|
|
import { getSignedUrl as getS3SignedUrl } from "@aws-sdk/s3-request-presigner"
|
|
import { env } from "./env"
|
|
import { createFileUploadSchema } from "./validations"
|
|
|
|
// Initialize S3 client (works with both AWS S3 and Cloudflare R2)
|
|
const s3Client = new S3Client({
|
|
region: env.AWS_REGION,
|
|
credentials: {
|
|
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
},
|
|
// For Cloudflare R2, use custom endpoint
|
|
...(env.AWS_ENDPOINT_URL && {
|
|
endpoint: env.AWS_ENDPOINT_URL,
|
|
forcePathStyle: true,
|
|
}),
|
|
})
|
|
|
|
/**
|
|
* Upload a file to S3/R2 storage
|
|
* @param file - File to upload
|
|
* @param path - Storage path (e.g., 'artists/portfolio/image.jpg')
|
|
* @param uploadedBy - User ID of the uploader
|
|
* @returns Promise with file upload metadata
|
|
*/
|
|
export async function uploadFile(
|
|
file: File,
|
|
path: string,
|
|
uploadedBy: string
|
|
): Promise<{
|
|
id: string
|
|
filename: string
|
|
originalName: string
|
|
mimeType: string
|
|
size: number
|
|
url: string
|
|
uploadedBy: string
|
|
}> {
|
|
// Validate file data
|
|
const fileData = createFileUploadSchema.parse({
|
|
filename: path,
|
|
originalName: file.name,
|
|
mimeType: file.type,
|
|
size: file.size,
|
|
uploadedBy,
|
|
})
|
|
|
|
// Generate unique filename to prevent conflicts
|
|
const timestamp = Date.now()
|
|
const randomString = Math.random().toString(36).substring(2, 15)
|
|
const extension = file.name.split('.').pop()
|
|
const uniqueFilename = `${timestamp}-${randomString}.${extension}`
|
|
const fullPath = `${path}/${uniqueFilename}`
|
|
|
|
try {
|
|
// Convert file to buffer
|
|
const buffer = await file.arrayBuffer()
|
|
|
|
// Upload to S3/R2
|
|
const command = new PutObjectCommand({
|
|
Bucket: env.AWS_BUCKET_NAME,
|
|
Key: fullPath,
|
|
Body: new Uint8Array(buffer),
|
|
ContentType: file.type,
|
|
ContentLength: file.size,
|
|
// Set cache control for images
|
|
CacheControl: 'public, max-age=31536000', // 1 year
|
|
// Add metadata
|
|
Metadata: {
|
|
originalName: file.name,
|
|
uploadedBy: uploadedBy,
|
|
uploadedAt: new Date().toISOString(),
|
|
},
|
|
})
|
|
|
|
await s3Client.send(command)
|
|
|
|
// Generate public URL
|
|
const url = env.AWS_ENDPOINT_URL
|
|
? `${env.AWS_ENDPOINT_URL}/${env.AWS_BUCKET_NAME}/${fullPath}`
|
|
: `https://${env.AWS_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com/${fullPath}`
|
|
|
|
return {
|
|
id: `${timestamp}-${randomString}`, // Generate ID from timestamp and random string
|
|
filename: uniqueFilename,
|
|
originalName: file.name,
|
|
mimeType: file.type,
|
|
size: file.size,
|
|
url,
|
|
uploadedBy,
|
|
}
|
|
} catch (error) {
|
|
console.error('File upload error:', error)
|
|
throw new Error('Failed to upload file')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a file from S3/R2 storage
|
|
* @param fileUrl - Full URL of the file to delete
|
|
*/
|
|
export async function deleteFile(fileUrl: string): Promise<void> {
|
|
try {
|
|
// Extract the key from the URL
|
|
const url = new URL(fileUrl)
|
|
const key = url.pathname.substring(1) // Remove leading slash
|
|
|
|
const command = new DeleteObjectCommand({
|
|
Bucket: env.AWS_BUCKET_NAME,
|
|
Key: key,
|
|
})
|
|
|
|
await s3Client.send(command)
|
|
} catch (error) {
|
|
console.error('File deletion error:', error)
|
|
throw new Error('Failed to delete file')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a signed URL for private file access
|
|
* @param key - File key in storage
|
|
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
|
|
* @returns Signed URL
|
|
*/
|
|
export async function getSignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
|
|
try {
|
|
const command = new GetObjectCommand({
|
|
Bucket: env.AWS_BUCKET_NAME,
|
|
Key: key,
|
|
})
|
|
|
|
const signedUrl = await getS3SignedUrl(s3Client, command, { expiresIn })
|
|
return signedUrl
|
|
} catch (error) {
|
|
console.error('Signed URL generation error:', error)
|
|
throw new Error('Failed to generate signed URL')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a presigned URL for direct client uploads
|
|
* @param key - File key in storage
|
|
* @param contentType - MIME type of the file
|
|
* @param expiresIn - URL expiration time in seconds (default: 15 minutes)
|
|
* @returns Presigned URL for PUT operation
|
|
*/
|
|
export async function getPresignedUploadUrl(
|
|
key: string,
|
|
contentType: string,
|
|
expiresIn: number = 900
|
|
): Promise<string> {
|
|
try {
|
|
const command = new PutObjectCommand({
|
|
Bucket: env.AWS_BUCKET_NAME,
|
|
Key: key,
|
|
ContentType: contentType,
|
|
})
|
|
|
|
const presignedUrl = await getS3SignedUrl(s3Client, command, { expiresIn })
|
|
return presignedUrl
|
|
} catch (error) {
|
|
console.error('Presigned URL generation error:', error)
|
|
throw new Error('Failed to generate presigned upload URL')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate file type for uploads
|
|
* @param file - File to validate
|
|
* @param allowedTypes - Array of allowed MIME types
|
|
* @returns Boolean indicating if file type is allowed
|
|
*/
|
|
export function validateFileType(file: File, allowedTypes: string[]): boolean {
|
|
return allowedTypes.includes(file.type)
|
|
}
|
|
|
|
/**
|
|
* Validate file size
|
|
* @param file - File to validate
|
|
* @param maxSizeBytes - Maximum file size in bytes
|
|
* @returns Boolean indicating if file size is acceptable
|
|
*/
|
|
export function validateFileSize(file: File, maxSizeBytes: number): boolean {
|
|
return file.size <= maxSizeBytes
|
|
}
|
|
|
|
/**
|
|
* Generate optimized file path for different file types
|
|
* @param type - File type ('portfolio', 'avatar', 'hero', etc.)
|
|
* @param artistId - Artist ID (optional)
|
|
* @returns Optimized storage path
|
|
*/
|
|
export function generateFilePath(type: 'portfolio' | 'avatar' | 'hero' | 'logo', artistId?: string): string {
|
|
const basePaths = {
|
|
portfolio: artistId ? `artists/${artistId}/portfolio` : 'portfolio',
|
|
avatar: artistId ? `artists/${artistId}/avatar` : 'avatars',
|
|
hero: 'site/hero',
|
|
logo: 'site/logo',
|
|
}
|
|
|
|
return basePaths[type]
|
|
}
|
|
|
|
/**
|
|
* Image processing utilities
|
|
*/
|
|
export const imageUtils = {
|
|
// Allowed image types
|
|
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'],
|
|
|
|
// Maximum file sizes (in bytes)
|
|
MAX_PORTFOLIO_SIZE: 10 * 1024 * 1024, // 10MB
|
|
MAX_AVATAR_SIZE: 5 * 1024 * 1024, // 5MB
|
|
MAX_HERO_SIZE: 15 * 1024 * 1024, // 15MB
|
|
|
|
// Image dimensions (for client-side validation)
|
|
PORTFOLIO_DIMENSIONS: { minWidth: 800, minHeight: 600 },
|
|
AVATAR_DIMENSIONS: { minWidth: 200, minHeight: 200 },
|
|
HERO_DIMENSIONS: { minWidth: 1920, minHeight: 1080 },
|
|
}
|
|
|
|
/**
|
|
* Error types for file upload operations
|
|
*/
|
|
export class FileUploadError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public code: 'INVALID_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED' | 'DELETE_FAILED'
|
|
) {
|
|
super(message)
|
|
this.name = 'FileUploadError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utility to handle file upload with validation
|
|
* @param file - File to upload
|
|
* @param type - Upload type
|
|
* @param artistId - Artist ID (optional)
|
|
* @param uploadedBy - User ID of uploader
|
|
* @returns Upload result
|
|
*/
|
|
export async function handleFileUpload(
|
|
file: File,
|
|
type: 'portfolio' | 'avatar' | 'hero' | 'logo',
|
|
uploadedBy: string,
|
|
artistId?: string
|
|
) {
|
|
// Validate file type
|
|
if (!validateFileType(file, imageUtils.ALLOWED_IMAGE_TYPES)) {
|
|
throw new FileUploadError('Invalid file type. Only images are allowed.', 'INVALID_TYPE')
|
|
}
|
|
|
|
// Validate file size based on type
|
|
const maxSizes = {
|
|
portfolio: imageUtils.MAX_PORTFOLIO_SIZE,
|
|
avatar: imageUtils.MAX_AVATAR_SIZE,
|
|
hero: imageUtils.MAX_HERO_SIZE,
|
|
logo: imageUtils.MAX_AVATAR_SIZE, // Same as avatar
|
|
}
|
|
|
|
if (!validateFileSize(file, maxSizes[type])) {
|
|
throw new FileUploadError(`File too large. Maximum size is ${maxSizes[type] / (1024 * 1024)}MB.`, 'FILE_TOO_LARGE')
|
|
}
|
|
|
|
// Generate file path
|
|
const path = generateFilePath(type, artistId)
|
|
|
|
try {
|
|
// Upload file
|
|
const result = await uploadFile(file, path, uploadedBy)
|
|
return result
|
|
} catch (error) {
|
|
throw new FileUploadError('Failed to upload file.', 'UPLOAD_FAILED')
|
|
}
|
|
}
|