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'; @Injectable() 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('idriveE2Storage.endpoint') || ''; this.region = this.configService.get('idriveE2Storage.region') || 'ap-southeast-1'; const accessKeyId = this.configService.get('idriveE2Storage.accessKey') || ''; const secretAccessKey = this.configService.get('idriveE2Storage.secretKey') || ''; this.bucket = this.configService.get('idriveE2Storage.bucket') || 'streamflix'; this.cdnUrl = this.configService.get('idriveE2Storage.cdnUrl') || 'https://idrive-e2-ap-southeast.aksharbhesaniya.dev'; this.cloudflareZoneId = this.configService.get( 'idriveE2Storage.cloudflareZoneId', ); this.cloudflareApiToken = this.configService.get( '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 = { 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 { 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 { 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 { 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 { 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); } } }