united-tattoo/lib/r2-upload.ts

356 lines
8.2 KiB
TypeScript

import { getR2Bucket } from '@/lib/db'
export interface R2UploadResponse {
success: boolean
url?: string
key?: string
error?: string
}
export interface BulkUploadResult {
successful: FileUploadResult[]
failed: { filename: string; error: string }[]
total: number
}
export interface FileUploadResult {
filename: string
url: string
key: string
size: number
mimeType: string
}
export interface FileUploadProgress {
id: string
filename: string
progress: number
status: 'uploading' | 'processing' | 'complete' | 'error'
url?: string
error?: string
}
/**
* File Upload Manager for Cloudflare R2
*/
export class FileUploadManager {
private bucket: R2Bucket
private baseUrl: string
constructor() {
this.bucket = getR2Bucket()
// R2 public URL format: https://<account-id>.r2.cloudflarestorage.com/<bucket-name>
this.baseUrl = process.env.R2_PUBLIC_URL || ''
}
/**
* Upload a single file to R2
*/
async uploadFile(
file: File | Buffer,
key: string,
options?: {
contentType?: string
metadata?: Record<string, string>
}
): Promise<R2UploadResponse> {
try {
const fileBuffer = file instanceof File ? await file.arrayBuffer() : file.buffer as ArrayBuffer
const contentType = options?.contentType || (file instanceof File ? file.type : 'application/octet-stream')
// Upload to R2
await this.bucket.put(key, fileBuffer, {
httpMetadata: {
contentType,
},
customMetadata: options?.metadata || {},
})
const url = `${this.baseUrl}/${key}`
return {
success: true,
url,
key,
}
} catch (error) {
console.error('R2 upload error:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
}
}
}
/**
* Upload multiple files to R2
*/
async bulkUpload(
files: File[],
keyPrefix: string = 'uploads'
): Promise<BulkUploadResult> {
const successful: FileUploadResult[] = []
const failed: { filename: string; error: string }[] = []
for (const file of files) {
try {
const key = `${keyPrefix}/${Date.now()}-${file.name}`
const result = await this.uploadFile(file, key, {
contentType: file.type,
metadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
})
if (result.success && result.url && result.key) {
successful.push({
filename: file.name,
url: result.url,
key: result.key,
size: file.size,
mimeType: file.type,
})
} else {
failed.push({
filename: file.name,
error: result.error || 'Upload failed',
})
}
} catch (error) {
failed.push({
filename: file.name,
error: error instanceof Error ? error.message : 'Upload failed',
})
}
}
return {
successful,
failed,
total: files.length,
}
}
/**
* Delete a file from R2
*/
async deleteFile(key: string): Promise<boolean> {
try {
await this.bucket.delete(key)
return true
} catch (error) {
console.error('R2 delete error:', error)
return false
}
}
/**
* Get file metadata from R2
*/
async getFileMetadata(key: string): Promise<R2Object | null> {
try {
return await this.bucket.get(key)
} catch (error) {
console.error('R2 metadata error:', error)
return null
}
}
/**
* Generate a presigned URL for direct upload
*/
async generatePresignedUrl(
key: string,
expiresIn: number = 3600
): Promise<string | null> {
try {
// Note: R2 presigned URLs require additional setup
// For now, we'll use direct upload through our API
return null
} catch (error) {
console.error('Presigned URL error:', error)
return null
}
}
/**
* Validate file before upload
*/
validateFile(file: File, options?: {
maxSize?: number
allowedTypes?: string[]
}): { valid: boolean; error?: string } {
const maxSize = options?.maxSize || 10 * 1024 * 1024 // 10MB default
const allowedTypes = options?.allowedTypes || [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
]
if (file.size > maxSize) {
return {
valid: false,
error: `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`,
}
}
if (!allowedTypes.includes(file.type)) {
return {
valid: false,
error: `File type ${file.type} not allowed`,
}
}
return { valid: true }
}
/**
* Generate a unique key for file upload
*/
generateFileKey(filename: string, prefix: string = 'uploads'): string {
const timestamp = Date.now()
const randomString = Math.random().toString(36).substring(2, 15)
const extension = filename.split('.').pop()
const baseName = filename.replace(/\.[^/.]+$/, '').replace(/[^a-zA-Z0-9]/g, '-')
return `${prefix}/${timestamp}-${randomString}-${baseName}.${extension}`
}
}
/**
* Convenience functions for common upload operations
*/
export async function uploadToR2(
file: File,
key?: string,
options?: {
contentType?: string
metadata?: Record<string, string>
}
): Promise<R2UploadResponse> {
const manager = new FileUploadManager()
const uploadKey = key || manager.generateFileKey(file.name)
return await manager.uploadFile(file, uploadKey, options)
}
export async function bulkUploadToR2(
files: File[],
keyPrefix?: string
): Promise<BulkUploadResult> {
const manager = new FileUploadManager()
return await manager.bulkUpload(files, keyPrefix)
}
export async function deleteFromR2(key: string): Promise<boolean> {
const manager = new FileUploadManager()
return await manager.deleteFile(key)
}
export function validateUploadFile(
file: File,
options?: {
maxSize?: number
allowedTypes?: string[]
}
): { valid: boolean; error?: string } {
const manager = new FileUploadManager()
return manager.validateFile(file, options)
}
/**
* Portfolio image specific upload functions
*/
export async function uploadPortfolioImage(
file: File,
artistId: string,
options?: {
caption?: string
tags?: string[]
}
): Promise<R2UploadResponse & { portfolioData?: any }> {
const manager = new FileUploadManager()
// Validate image file
const validation = manager.validateFile(file, {
maxSize: 5 * 1024 * 1024, // 5MB for portfolio images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
if (!validation.valid) {
return {
success: false,
error: validation.error,
}
}
// Generate key for portfolio image
const key = manager.generateFileKey(file.name, `portfolio/${artistId}`)
// Upload to R2
const uploadResult = await manager.uploadFile(file, key, {
contentType: file.type,
metadata: {
artistId,
originalName: file.name,
caption: options?.caption || '',
tags: JSON.stringify(options?.tags || []),
uploadedAt: new Date().toISOString(),
},
})
if (uploadResult.success) {
return {
...uploadResult,
portfolioData: {
artistId,
url: uploadResult.url,
caption: options?.caption,
tags: options?.tags || [],
},
}
}
return uploadResult
}
/**
* Artist profile image upload
*/
export async function uploadArtistProfileImage(
file: File,
artistId: string
): Promise<R2UploadResponse> {
const manager = new FileUploadManager()
// Validate image file
const validation = manager.validateFile(file, {
maxSize: 2 * 1024 * 1024, // 2MB for profile images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
if (!validation.valid) {
return {
success: false,
error: validation.error,
}
}
// Generate key for profile image
const key = `profiles/${artistId}/profile-${Date.now()}.${file.name.split('.').pop()}`
return await manager.uploadFile(file, key, {
contentType: file.type,
metadata: {
artistId,
type: 'profile',
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
})
}