streamflix-api / src /shared /modules /upload /idrive-e2-storage /idrive-e2-storage.service.ts
Akshar2325
feat(idrive-e2-storage): add CDN and image resizing support
036d801
Raw
History Blame
11.8 kB
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<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);
}
}
}