Spaces:
Runtime error
Runtime error
| import { Injectable, Logger, NotFoundException } from '@nestjs/common'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { | |
| S3Client, | |
| PutObjectCommand, | |
| DeleteObjectCommand, | |
| GetObjectCommand, | |
| } from '@aws-sdk/client-s3'; | |
| import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; | |
| import sharp from 'sharp'; | |
| () | |
| export class IdriveE2StorageService { | |
| private readonly logger = new Logger(IdriveE2StorageService.name); | |
| private s3Client: S3Client; | |
| private bucket: string; | |
| private region: string; | |
| private endpoint: string; | |
| private cdnUrl: string; | |
| private cloudflareZoneId?: string; | |
| private cloudflareApiToken?: string; | |
| constructor(private configService: ConfigService) { | |
| this.endpoint = | |
| this.configService.get<string>('idriveE2Storage.endpoint') || ''; | |
| this.region = | |
| this.configService.get<string>('idriveE2Storage.region') || | |
| 'ap-southeast-1'; | |
| const accessKeyId = | |
| this.configService.get<string>('idriveE2Storage.accessKey') || ''; | |
| const secretAccessKey = | |
| this.configService.get<string>('idriveE2Storage.secretKey') || ''; | |
| this.bucket = | |
| this.configService.get<string>('idriveE2Storage.bucket') || 'streamflix'; | |
| this.cdnUrl = | |
| this.configService.get<string>('idriveE2Storage.cdnUrl') || | |
| 'https://idrive-e2-ap-southeast.aksharbhesaniya.dev'; | |
| this.cloudflareZoneId = this.configService.get<string>( | |
| 'idriveE2Storage.cloudflareZoneId', | |
| ); | |
| this.cloudflareApiToken = this.configService.get<string>( | |
| 'idriveE2Storage.cloudflareApiToken', | |
| ); | |
| this.s3Client = new S3Client({ | |
| region: this.region, | |
| endpoint: this.endpoint, | |
| credentials: { | |
| accessKeyId, | |
| secretAccessKey, | |
| }, | |
| forcePathStyle: true, | |
| }); | |
| } | |
| /** | |
| * Get folder path based on file type | |
| */ | |
| private getFolderPath(fileType: string): string { | |
| const folderMap: Record<string, string> = { | |
| IMAGE: 'images', | |
| DOCUMENT: 'documents', | |
| VIDEO: 'videos', | |
| }; | |
| return folderMap[fileType.toUpperCase()] || 'others'; | |
| } | |
| /** | |
| * Generate presigned upload URL using S3 | |
| */ | |
| async getPresignedUploadUrl(fileName: string, fileType?: string) { | |
| try { | |
| const folder = fileType ? this.getFolderPath(fileType) : 'others'; | |
| const timestamp = Date.now(); | |
| const filePath = `${folder}/${timestamp}-${fileName}`; | |
| const command = new PutObjectCommand({ | |
| Bucket: this.bucket, | |
| Key: filePath, | |
| }); | |
| const uploadUrl = await getSignedUrl(this.s3Client, command, { | |
| expiresIn: 3600, | |
| }); | |
| // Construct the permanent public URL through Cloudflare CDN | |
| const fileUrl = `${this.cdnUrl}/${filePath}`; | |
| this.logger.log( | |
| `Generated presigned upload URL for: ${filePath}${fileType ? ` (type: ${fileType})` : ''}`, | |
| ); | |
| return { | |
| uploadUrl, | |
| fileUrl, // Permanent CDN URL (use this for access/delete) | |
| filePath, | |
| expiresIn: 3600, | |
| ...(fileType && { fileType }), | |
| }; | |
| } catch (error) { | |
| this.logger.error('Failed to generate presigned upload URL', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Get presigned download URL for an existing file | |
| */ | |
| async getPresignedDownloadUrl( | |
| filePath: string, | |
| expiresIn: number = 3600, | |
| ): Promise<any> { | |
| try { | |
| const command = new GetObjectCommand({ | |
| Bucket: this.bucket, | |
| Key: filePath, | |
| }); | |
| const downloadUrl = await getSignedUrl(this.s3Client, command, { | |
| expiresIn, | |
| }); | |
| this.logger.log( | |
| `Generated presigned download URL for: ${filePath} (expires in ${expiresIn}s)`, | |
| ); | |
| return { | |
| downloadUrl, | |
| filePath, | |
| expiresIn, | |
| }; | |
| } catch (error) { | |
| this.logger.error('Failed to generate presigned download URL', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Delete a file from IDrive e2 | |
| */ | |
| async deleteFile(filePath: string): Promise<any> { | |
| try { | |
| const command = new DeleteObjectCommand({ | |
| Bucket: this.bucket, | |
| Key: filePath, | |
| }); | |
| const result = await this.s3Client.send(command); | |
| this.logger.log(`Successfully deleted file: ${filePath}`); | |
| return { | |
| success: true, | |
| deletedPath: filePath, | |
| result, | |
| }; | |
| } catch (error) { | |
| this.logger.error(`Failed to delete file: ${filePath}`, error); | |
| throw new NotFoundException('File not found or already deleted'); | |
| } | |
| } | |
| /** | |
| * Delete a file by CDN URL | |
| */ | |
| async deleteFileByUrl(url: string): Promise<any> { | |
| try { | |
| const filePath = this.extractFilePathFromUrl(url); | |
| if (!filePath) { | |
| throw new NotFoundException('Could not extract file path from URL'); | |
| } | |
| // Delete from IDrive e2 | |
| const result = await this.deleteFile(filePath); | |
| // Purge Cloudflare cache | |
| await this.purgeCloudflareCache(url); | |
| this.logger.log(`Successfully deleted file from URL: ${url}`); | |
| return { | |
| ...result, | |
| deletedUrl: url, | |
| cachePurged: true, | |
| }; | |
| } catch (error) { | |
| if (error instanceof NotFoundException) { | |
| throw error; | |
| } | |
| this.logger.error(`Failed to delete file by URL: ${url}`, error); | |
| throw new NotFoundException('File not found or already deleted'); | |
| } | |
| } | |
| /** | |
| * Extract file path from CDN URL | |
| */ | |
| private extractFilePathFromUrl(url: string): string | null { | |
| try { | |
| // Match pattern: https://idrive-e2-ap-southeast.aksharbhesaniya.dev/{path} | |
| const match = url.match(/^https?:\/\/[^/]+\/(.+)$/); | |
| return match ? match[1] : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Resize image from IDrive e2 using Sharp | |
| */ | |
| async resizeImage( | |
| filePath: string, | |
| width?: number, | |
| height?: number, | |
| quality?: number, | |
| ): Promise<{ buffer: Buffer; contentType: string }> { | |
| try { | |
| // Fetch file from IDrive e2 | |
| const command = new GetObjectCommand({ | |
| Bucket: this.bucket, | |
| Key: filePath, | |
| }); | |
| const result = await this.s3Client.send(command); | |
| if (!result.Body) { | |
| throw new NotFoundException('File not found'); | |
| } | |
| // Convert stream to buffer | |
| const chunks: Uint8Array[] = []; | |
| for await (const chunk of result.Body as any) { | |
| chunks.push(chunk); | |
| } | |
| const imageBuffer = Buffer.concat(chunks); | |
| // Determine output format from content type | |
| const contentType = result.ContentType || 'image/jpeg'; | |
| const isJpeg = | |
| contentType.includes('jpeg') || contentType.includes('jpg'); | |
| const isPng = contentType.includes('png'); | |
| const isWebp = contentType.includes('webp'); | |
| // Initialize Sharp with high-quality settings | |
| let sharpInstance = sharp(imageBuffer, { | |
| failOnError: false, | |
| unlimited: true, | |
| }); | |
| // Get image metadata | |
| const metadata = await sharpInstance.metadata(); | |
| // Apply resize if dimensions provided | |
| if (width || height) { | |
| sharpInstance = sharpInstance.resize(width, height, { | |
| fit: 'inside', | |
| withoutEnlargement: true, | |
| kernel: sharp.kernel.lanczos3, | |
| }); | |
| } | |
| // Set quality (default 90) | |
| const finalQuality = quality || 90; | |
| // Apply format-specific optimizations | |
| if (isJpeg) { | |
| sharpInstance = sharpInstance.jpeg({ | |
| quality: finalQuality, | |
| progressive: true, | |
| chromaSubsampling: '4:4:4', | |
| mozjpeg: true, | |
| optimizeScans: true, | |
| trellisQuantisation: true, | |
| overshootDeringing: true, | |
| }); | |
| } else if (isPng) { | |
| sharpInstance = sharpInstance.png({ | |
| quality: finalQuality, | |
| compressionLevel: 9, | |
| adaptiveFiltering: true, | |
| palette: metadata.channels === 4, | |
| }); | |
| } else if (isWebp) { | |
| sharpInstance = sharpInstance.webp({ | |
| quality: finalQuality, | |
| lossless: finalQuality >= 95, | |
| nearLossless: finalQuality >= 90, | |
| smartSubsample: true, | |
| effort: 6, | |
| }); | |
| } else { | |
| sharpInstance = sharpInstance.jpeg({ | |
| quality: finalQuality, | |
| progressive: true, | |
| mozjpeg: true, | |
| }); | |
| } | |
| // Apply sharpening | |
| if (width || height) { | |
| sharpInstance = sharpInstance.sharpen({ | |
| sigma: 0.5, | |
| m1: 1.0, | |
| m2: 0.2, | |
| }); | |
| } | |
| const resizedBuffer = await sharpInstance.toBuffer(); | |
| this.logger.log( | |
| `Resized image: ${filePath} (${width || 'auto'}x${height || 'auto'}, q:${finalQuality})`, | |
| ); | |
| return { | |
| buffer: resizedBuffer, | |
| contentType, | |
| }; | |
| } catch (error) { | |
| if ( | |
| error.Code === 'NoSuchKey' || | |
| error.$metadata?.httpStatusCode === 404 | |
| ) { | |
| this.logger.warn(`File not found in IDrive e2: ${filePath}`); | |
| throw new NotFoundException('File not found'); | |
| } | |
| this.logger.error(`Failed to resize image: ${filePath}`, error); | |
| throw new NotFoundException('Resize failed'); | |
| } | |
| } | |
| /** | |
| * Purge Cloudflare cache for all variants | |
| */ | |
| private async purgeCloudflareCache(url: string): Promise<void> { | |
| if (!this.cloudflareZoneId || !this.cloudflareApiToken) { | |
| this.logger.warn( | |
| 'Cloudflare credentials not configured - skipping cache purge', | |
| ); | |
| return; | |
| } | |
| try { | |
| const urlsToPurge: string[] = [url]; | |
| const widths = [ | |
| 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 600, 700, 800, 900, | |
| 1000, 1200, 1400, 1600, 1800, 1920, 2048, 2560, 3840, | |
| ]; | |
| const heights = [ | |
| 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 600, 700, 800, 900, | |
| 1000, 1080, 1200, 1440, 2160, | |
| ]; | |
| const qualities = [60, 70, 75, 80, 85, 90, 95, 100]; | |
| widths.forEach((w) => { | |
| urlsToPurge.push(`${url}?w=${w}`); | |
| qualities.forEach((q) => { | |
| urlsToPurge.push(`${url}?w=${w}&q=${q}`); | |
| }); | |
| }); | |
| heights.forEach((h) => { | |
| urlsToPurge.push(`${url}?h=${h}`); | |
| }); | |
| const squareSizes = [ | |
| 50, 100, 150, 200, 250, 300, 400, 500, 600, 800, 1000, 1200, | |
| ]; | |
| squareSizes.forEach((size) => { | |
| urlsToPurge.push(`${url}?w=${size}&h=${size}`); | |
| qualities.forEach((q) => { | |
| urlsToPurge.push(`${url}?w=${size}&h=${size}&q=${q}`); | |
| }); | |
| }); | |
| const batchSize = 30; | |
| const batches: string[][] = []; | |
| for (let i = 0; i < urlsToPurge.length; i += batchSize) { | |
| batches.push(urlsToPurge.slice(i, i + batchSize)); | |
| } | |
| const purgePromises = batches.map((batch) => | |
| fetch( | |
| `https://api.cloudflare.com/client/v4/zones/${this.cloudflareZoneId}/purge_cache`, | |
| { | |
| method: 'POST', | |
| headers: { | |
| Authorization: `Bearer ${this.cloudflareApiToken}`, | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| files: batch, | |
| }), | |
| }, | |
| ), | |
| ); | |
| const results = await Promise.all(purgePromises); | |
| const allSucceeded = results.every((r) => r.ok); | |
| if (!allSucceeded) { | |
| const errors = await Promise.all( | |
| results.filter((r) => !r.ok).map((r) => r.text()), | |
| ); | |
| this.logger.error( | |
| `Some Cloudflare cache purges failed: ${errors.join(', ')}`, | |
| ); | |
| } else { | |
| this.logger.log( | |
| `Successfully purged Cloudflare cache for: ${url} (${urlsToPurge.length} variants)`, | |
| ); | |
| } | |
| } catch (error) { | |
| this.logger.error('Error purging Cloudflare cache:', error); | |
| } | |
| } | |
| } | |