This commit is contained in:
@@ -11,8 +11,8 @@ import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||
export class UploaderController {
|
||||
constructor(private readonly uploaderService: UploaderService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
// @UseGuards(AuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Upload a file (admin)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export interface ProcessedImage {
|
||||
buffer: Buffer;
|
||||
contentType: 'image/webp';
|
||||
extension: 'webp';
|
||||
}
|
||||
|
||||
const RASTER_IMAGE_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/tiff',
|
||||
'image/bmp',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class ImageProcessingService {
|
||||
private readonly logger = new Logger(ImageProcessingService.name);
|
||||
private readonly maxWidth: number;
|
||||
private readonly maxHeight: number;
|
||||
private readonly maxOutputBytes: number;
|
||||
private readonly webpQuality: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.maxWidth = this.configService.get<number>('IMAGE_MAX_WIDTH', 1920);
|
||||
this.maxHeight = this.configService.get<number>('IMAGE_MAX_HEIGHT', 1920);
|
||||
this.maxOutputBytes = this.configService.get<number>('IMAGE_MAX_SIZE', 2 * 1024 * 1024);
|
||||
this.webpQuality = this.configService.get<number>('IMAGE_WEBP_QUALITY', 80);
|
||||
}
|
||||
|
||||
shouldProcess(mimetype: string): boolean {
|
||||
return RASTER_IMAGE_MIME_TYPES.has(mimetype);
|
||||
}
|
||||
|
||||
async processImage(buffer: Buffer, mimetype: string): Promise<ProcessedImage | null> {
|
||||
if (!this.shouldProcess(mimetype)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = sharp(buffer, { animated: mimetype === 'image/gif' }).rotate();
|
||||
const metadata = await source.metadata();
|
||||
|
||||
let targetWidth = metadata.width;
|
||||
let targetHeight = metadata.height;
|
||||
|
||||
if (targetWidth && targetHeight && (targetWidth > this.maxWidth || targetHeight > this.maxHeight)) {
|
||||
const scale = Math.min(this.maxWidth / targetWidth, this.maxHeight / targetHeight);
|
||||
targetWidth = Math.round(targetWidth * scale);
|
||||
targetHeight = Math.round(targetHeight * scale);
|
||||
}
|
||||
|
||||
let quality = this.webpQuality;
|
||||
let output = await this.renderWebp(source, targetWidth, targetHeight, quality);
|
||||
|
||||
while (output.length > this.maxOutputBytes) {
|
||||
if (quality > 50) {
|
||||
quality -= 10;
|
||||
output = await this.renderWebp(source, targetWidth, targetHeight, quality);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!targetWidth || !targetHeight || (targetWidth <= 800 && targetHeight <= 800)) {
|
||||
break;
|
||||
}
|
||||
|
||||
targetWidth = Math.round(targetWidth * 0.85);
|
||||
targetHeight = Math.round(targetHeight * 0.85);
|
||||
quality = this.webpQuality;
|
||||
output = await this.renderWebp(source, targetWidth, targetHeight, quality);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Image processed: ${metadata.width}x${metadata.height} -> ${targetWidth}x${targetHeight}, ` +
|
||||
`${buffer.length} -> ${output.length} bytes (webp q${quality})`,
|
||||
);
|
||||
|
||||
return {
|
||||
buffer: output,
|
||||
contentType: 'image/webp',
|
||||
extension: 'webp',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to process image: ${(error as Error).message}`, (error as Error).stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private renderWebp(
|
||||
source: sharp.Sharp,
|
||||
width: number | undefined,
|
||||
height: number | undefined,
|
||||
quality: number,
|
||||
): Promise<Buffer> {
|
||||
let pipeline = source.clone().rotate();
|
||||
|
||||
if (width && height) {
|
||||
pipeline = pipeline.resize(width, height, { fit: 'inside', withoutEnlargement: true });
|
||||
}
|
||||
|
||||
return pipeline.webp({ quality }).toBuffer();
|
||||
}
|
||||
}
|
||||
@@ -13,28 +13,23 @@ export class S3Service {
|
||||
private readonly logger = new Logger(S3Service.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly region: string; // Region is often 'us-east-1' or similar for Liara, but depends on your config
|
||||
private readonly endpointUrl: string; // Renamed 'url' to 'endpointUrl' for clarity
|
||||
private readonly region: string;
|
||||
private readonly endpointUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// --- Configuration for Liara S3 ---
|
||||
this.bucketName = this.configService.getOrThrow<string>('BUCKET_NAME');
|
||||
// Liara S3 usually requires a specific endpoint URL
|
||||
this.endpointUrl = this.configService.getOrThrow<string>('LIARA_S3_ENDPOINT');
|
||||
// The region for Liara S3 is often 'us-east-1' or just a placeholder,
|
||||
// but we'll keep it configurable for consistency.
|
||||
this.endpointUrl = this.configService.getOrThrow<string>('PARSPACK_S3_ENDPOINT');
|
||||
this.region = this.configService.getOrThrow<string>('BUCKET_REGION');
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region: this.region, // Use the configured region
|
||||
endpoint: this.endpointUrl, // This is the crucial change for Liara S3
|
||||
forcePathStyle: true, // Often required when using custom S3 endpoints like Liara
|
||||
region: this.region,
|
||||
endpoint: this.endpointUrl,
|
||||
forcePathStyle: true, // Required for ParsPack S3-compatible API
|
||||
credentials: {
|
||||
accessKeyId: this.configService.getOrThrow<string>('BUCKET_ACCESS_KEY'),
|
||||
secretAccessKey: this.configService.getOrThrow<string>('BUCKET_SECRET_KEY'),
|
||||
},
|
||||
});
|
||||
// ------------------------------------
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,16 +44,15 @@ export class S3Service {
|
||||
try {
|
||||
const sanitizedMetadata = metadata
|
||||
? Object.entries(metadata).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Encode all values to be ASCII-safe
|
||||
acc[key] = encodeURIComponent(value);
|
||||
(acc, [metaKey, value]) => {
|
||||
// S3 user metadata must be US-ASCII; base64 keeps signing stable for Unicode names
|
||||
acc[metaKey] = Buffer.from(value, 'utf8').toString('base64');
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
// NOTE: 'ACL: public-read' might not be supported or necessary for Liara S3,
|
||||
// depending on your bucket configuration on Liara.
|
||||
// NOTE: 'ACL: public-read' might not be supported or necessary for ParsPack S3.
|
||||
// If uploads fail, try removing this line.
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
@@ -71,9 +65,8 @@ export class S3Service {
|
||||
|
||||
await this.s3Client.send(command);
|
||||
|
||||
// --- URL for Liara S3 ---
|
||||
// The public URL for Liara S3 files is typically: ${endpoint}/${bucketName}/${key}
|
||||
const url = `${this.endpointUrl}/${this.bucketName}/${key}`;
|
||||
const encodedKey = key.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
const url = `${this.endpointUrl}/${this.bucketName}/${encodedKey}`;
|
||||
|
||||
this.logger.log(`File uploaded to S3: ${key}`);
|
||||
|
||||
@@ -88,7 +81,6 @@ export class S3Service {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other methods remain the same as they use the S3Client which is now configured for Liara ---
|
||||
|
||||
/**
|
||||
* Get file stream from S3
|
||||
@@ -153,10 +145,10 @@ export class S3Service {
|
||||
/**
|
||||
* Generate S3 key for file
|
||||
*/
|
||||
generateFileKey(originalName: string, fileType: FileType): string {
|
||||
generateFileKey(originalName: string, fileType: FileType, extensionOverride?: string): string {
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = randomInt(1000000, 9999999);
|
||||
const extension = originalName.split('.').pop();
|
||||
const extension = extensionOverride ?? this.extractSafeExtension(originalName);
|
||||
|
||||
const basePathMap: Record<FileType, string> = {
|
||||
image: 'images',
|
||||
@@ -170,4 +162,21 @@ export class S3Service {
|
||||
const basePath = basePathMap[fileType];
|
||||
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a safe ASCII extension so Persian/spaced names never end up in the S3 object key.
|
||||
*/
|
||||
private extractSafeExtension(originalName: string): string {
|
||||
const basename = originalName.replace(/\\/g, '/').split('/').pop() ?? 'file';
|
||||
const lastDot = basename.lastIndexOf('.');
|
||||
|
||||
if (lastDot > 0 && lastDot < basename.length - 1) {
|
||||
const ext = basename.slice(lastDot + 1).toLowerCase();
|
||||
if (/^[a-z0-9]{1,16}$/.test(ext)) {
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
|
||||
return 'bin';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { File } from '@nest-lab/fastify-multer';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { ImageProcessingService } from './image-processing.service';
|
||||
import { S3Service } from './s3.service';
|
||||
import { UploaderMessage } from '../../../common/enums/message.enum';
|
||||
import { FileType } from '../interfaces/file.interface';
|
||||
@@ -15,6 +16,7 @@ export class UploaderService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly s3Service: S3Service,
|
||||
private readonly imageProcessingService: ImageProcessingService,
|
||||
) {
|
||||
this.maxFileSize = this.configService.get('MAX_FILE_SIZE', 1024 * 1024 * 100); // 100MB
|
||||
this.allowedMimeTypes = [
|
||||
@@ -80,10 +82,22 @@ export class UploaderService {
|
||||
|
||||
const fileType = await this.determineFileType(file.mimetype);
|
||||
|
||||
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType);
|
||||
let uploadBuffer = file.buffer;
|
||||
let contentType = file.mimetype;
|
||||
let extensionOverride: string | undefined;
|
||||
|
||||
// Upload to S3
|
||||
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
|
||||
if (fileType === 'image') {
|
||||
const processed = await this.imageProcessingService.processImage(file.buffer, file.mimetype);
|
||||
if (processed) {
|
||||
uploadBuffer = processed.buffer;
|
||||
contentType = processed.contentType;
|
||||
extensionOverride = processed.extension;
|
||||
}
|
||||
}
|
||||
|
||||
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType, extensionOverride);
|
||||
|
||||
const s3Result = await this.s3Service.uploadFile(uploadBuffer, s3Key, contentType, {
|
||||
originalName: file.originalname,
|
||||
uploadedBy: 'admin',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImageProcessingService } from './providers/image-processing.service';
|
||||
import { S3Service } from './providers/s3.service';
|
||||
import { UploaderService } from './providers/uploader.service';
|
||||
import { UploaderController } from './controllers/uploader.controller';
|
||||
@@ -7,8 +8,8 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [UploaderController],
|
||||
providers: [UploaderService, S3Service],
|
||||
exports: [UploaderService, S3Service],
|
||||
providers: [UploaderService, S3Service, ImageProcessingService],
|
||||
exports: [UploaderService, S3Service, ImageProcessingService],
|
||||
imports: [JwtModule],
|
||||
})
|
||||
export class UploaderModule {}
|
||||
|
||||
Reference in New Issue
Block a user