import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import type { FileMetadata, FileUploadConfig, PresignedUrlResult, UploadOptions, UploadResult, } from "./data"; import { generateFileHash, generateObjectKey, isDocumentFile, isImageFile, isVideoFile, } from "./utils"; import { processDocument } from "./processors/document-processor"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { processVideo } from "./processors/video-processor"; import { processImage } from "./processors/image-processor"; import { ERROR_CODES, type Result } from "@pkg/result"; import { getError, logger } from "@pkg/logger"; import { validateFile } from "./validation"; export class R2StorageClient { private s3Client: S3Client; private config: FileUploadConfig; constructor(config: FileUploadConfig) { this.config = config; this.s3Client = new S3Client({ region: config.region, endpoint: config.endpoint, credentials: { accessKeyId: config.accessKey, secretAccessKey: config.secretKey, }, }); } /** * Upload a file directly to R2 */ async uploadFile( file: Buffer | Uint8Array, originalName: string, mimeType: string, userId: string, options?: UploadOptions, ): Promise> { try { // Validate file const validationResult = validateFile( file, originalName, mimeType, this.config, ); if (!validationResult.isValid) { return { error: getError({ code: ERROR_CODES.VALIDATION_ERROR, message: "File validation failed", description: validationResult.errors.join(", "), detail: "File validation failed", }), }; } // Generate file hash for deduplication const hash = generateFileHash(file); // Generate unique filename and object key const fileId = crypto.randomUUID(); const extension = originalName.split(".").pop() || ""; const filename = `${fileId}.${extension}`; const objectKey = generateObjectKey(userId, filename); let processedFile = Buffer.from(file); let thumbnailBuffer: Buffer | undefined; let metadata = options?.metadata ? { ...options?.metadata } : {}; // Process file based on type if (options?.processImage && isImageFile(mimeType)) { const processingResult = await processImage(file, { format: "webp", quality: 85, generateThumbnail: true, thumbnailSize: { width: 300, height: 300 }, resize: { width: 1920, height: 1920, fit: "inside", }, }); if ( processingResult.processed && processingResult.processedFile ) { processedFile = Buffer.from(processingResult.processedFile); thumbnailBuffer = processingResult.thumbnail ? Buffer.from(processingResult.thumbnail) : undefined; metadata = { ...metadata, ...processingResult.metadata }; } } else if (options?.processDocument && isDocumentFile(mimeType)) { const processingResult = await processDocument(file, mimeType, { extractText: true, generatePreview: true, extractMetadata: true, }); if (processingResult.processed && processingResult.metadata) { metadata = { ...metadata, ...processingResult.metadata }; } } else if (options?.processVideo && isVideoFile(mimeType)) { const processingResult = await processVideo(file, mimeType, { generateThumbnail: true, extractMetadata: true, thumbnailTimestamp: 1, // 1 second into video }); if (processingResult.processed && processingResult.metadata) { metadata = { ...metadata, ...processingResult.metadata }; } } // Upload main file to R2 const uploadCommand = new PutObjectCommand({ Bucket: this.config.bucketName, Key: objectKey, Body: processedFile, ContentType: mimeType, Metadata: { originalName, userId, hash, uploadId: fileId, processed: "true", ...Object.fromEntries( Object.entries(metadata).map(([key, value]) => [ key, typeof value === "string" ? value : JSON.stringify(value), ]), ), }, }); await this.s3Client.send(uploadCommand); // Upload thumbnail if generated if (thumbnailBuffer) { const thumbnailKey = `thumbnails/${userId}/${fileId}_thumb.webp`; const thumbnailCommand = new PutObjectCommand({ Bucket: this.config.bucketName, Key: thumbnailKey, Body: thumbnailBuffer, ContentType: "image/webp", Metadata: { originalFileId: fileId, type: "thumbnail", }, }); await this.s3Client.send(thumbnailCommand); metadata.thumbnailKey = thumbnailKey; } // Construct R2 URL const r2Url = `${this.config.publicUrl || this.config.endpoint}/${objectKey}`; const fileMetadata: FileMetadata = { id: fileId, filename, originalName, mimeType, size: processedFile.length, hash, bucketName: this.config.bucketName, objectKey, r2Url, visibility: options?.visibility || "private", userId, metadata, tags: options?.tags, uploadedAt: new Date(), }; const result: UploadResult = { success: true, file: fileMetadata, uploadId: fileId, }; logger.info( `Successfully uploaded file ${fileId} for user ${userId}`, ); return { data: result }; } catch (error) { logger.error("File upload failed:", error); return { error: getError( { code: ERROR_CODES.STORAGE_ERROR, message: "Upload failed", description: "Failed to upload file to storage", detail: "S3 upload operation failed", }, error, ), }; } } /** * Generate presigned URL for direct upload */ async generatePresignedUploadUrl( objectKey: string, mimeType: string, expiresIn: number = 3600, ): Promise> { try { const command = new PutObjectCommand({ Bucket: this.config.bucketName, Key: objectKey, ContentType: mimeType, }); const uploadUrl = await getSignedUrl(this.s3Client, command, { expiresIn, }); const result: PresignedUrlResult = { uploadUrl, expiresIn, }; logger.info(`Generated presigned URL for ${objectKey}`); return { data: result }; } catch (error) { logger.error("Failed to generate presigned URL:", error); return { error: getError( { code: ERROR_CODES.STORAGE_ERROR, message: "Failed to generate presigned URL", description: "Could not create upload URL", detail: "S3 presigned URL generation failed", }, error, ), }; } } /** * Get file from R2 */ async getFile(objectKey: string): Promise> { try { const command = new GetObjectCommand({ Bucket: this.config.bucketName, Key: objectKey, }); const response = await this.s3Client.send(command); const body = response.Body; if (!body) { return { error: getError({ code: ERROR_CODES.NOT_FOUND, message: "File not found", description: "The requested file does not exist", detail: "S3 response body is empty", }), }; } // Handle different response body types if (body instanceof Uint8Array) { return { data: Buffer.from(body) }; } // For Node.js Readable streams (AWS SDK v3) if (typeof body.transformToByteArray === "function") { const byteArray = await body.transformToByteArray(); return { data: Buffer.from(byteArray) }; } // Fallback: treat as readable stream const chunks: Buffer[] = []; // Type assertion to handle the stream properly const stream = body as NodeJS.ReadableStream; return new Promise>((resolve, reject) => { stream.on("data", (chunk: Buffer) => { chunks.push(chunk); }); stream.on("end", () => { const buffer = Buffer.concat(chunks); logger.info(`Successfully retrieved file ${objectKey}`); resolve({ data: buffer }); }); stream.on("error", (error) => { reject(error); }); }); } catch (error) { logger.error(`Failed to get file ${objectKey}:`, error); return { error: getError( { code: ERROR_CODES.STORAGE_ERROR, message: "Failed to get file", description: "Could not retrieve file from storage", detail: "S3 get operation failed", }, error, ), }; } } /** * Delete file from R2 */ async deleteFile(objectKey: string): Promise> { try { const command = new DeleteObjectCommand({ Bucket: this.config.bucketName, Key: objectKey, }); await this.s3Client.send(command); logger.info(`Successfully deleted file ${objectKey}`); return { data: true }; } catch (error) { logger.error(`Failed to delete file ${objectKey}:`, error); return { error: getError( { code: ERROR_CODES.STORAGE_ERROR, message: "Failed to delete file", description: "Could not delete file from storage", detail: "S3 delete operation failed", }, error, ), }; } } /** * Get file metadata from R2 */ async getFileMetadata( objectKey: string, ): Promise>> { try { const command = new HeadObjectCommand({ Bucket: this.config.bucketName, Key: objectKey, }); const response = await this.s3Client.send(command); const metadata = { size: response.ContentLength, lastModified: response.LastModified, contentType: response.ContentType, metadata: response.Metadata || {}, }; logger.info(`Successfully retrieved metadata for ${objectKey}`); return { data: metadata }; } catch (error) { logger.error( `Failed to get file metadata for ${objectKey}:`, error, ); return { error: getError( { code: ERROR_CODES.STORAGE_ERROR, message: "Failed to get file metadata", description: "Could not retrieve file information", detail: "S3 head operation failed", }, error, ), }; } } /** * Check if a file exists in R2 */ async fileExists(objectKey: string): Promise> { try { const command = new HeadObjectCommand({ Bucket: this.config.bucketName, Key: objectKey, }); await this.s3Client.send(command); return { data: true }; } catch (error: any) { if ( error.name === "NotFound" || error.$metadata?.httpStatusCode === 404 ) { return { data: false }; } logger.error( `Failed to check file existence for ${objectKey}:`, error, ); return { error: getError( { code: ERROR_CODES.STORAGE_ERROR, message: "Failed to check file existence", description: "Could not verify if file exists", detail: "S3 head operation failed", }, error, ), }; } } /** * Copy file within R2 */ async copyFile( sourceKey: string, destinationKey: string, ): Promise> { try { const command = new CopyObjectCommand({ Bucket: this.config.bucketName, Key: destinationKey, CopySource: `${this.config.bucketName}/${sourceKey}`, }); await this.s3Client.send(command); logger.info( `Successfully copied file from ${sourceKey} to ${destinationKey}`, ); return { data: true }; } catch (error) { logger.error( `Failed to copy file from ${sourceKey} to ${destinationKey}:`, error, ); return { error: getError( { code: ERROR_CODES.STORAGE_ERROR, message: "Failed to copy file", description: "Could not copy file in storage", detail: "S3 copy operation failed", }, error, ), }; } } }