import { Injectable, BadRequestException } from '@nestjs/common'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { Logger } from 'nestjs-pino'; import { mkdir, writeFile } from 'fs/promises'; import { randomUUID } from 'crypto'; import * as path from 'path'; import { encryptImageWithHeader } from '@box/common/utils/image-lib'; type ImageType = 'ads-cover' | 'video-cover'; interface UploadResult { key: string; imgSource: 'LOCAL_ONLY' | 'S3_AND_LOCAL'; } @Injectable() export class ImageUploadService { private readonly s3Client?: S3Client; private readonly localRoot: string; private readonly s3Enabled: boolean; private readonly s3Bucket?: string; constructor(private readonly logger: Logger) { this.localRoot = process.env.BOX_IMAGE_LOCAL_ROOT || '/tmp/box-images'; this.s3Enabled = process.env.BOX_IMAGE_S3_ENABLED === 'true'; if (this.s3Enabled) { this.s3Bucket = process.env.AWS_STORAGE_BUCKET_NAME; if (!this.s3Bucket) { this.logger.warn( '[ImageUploadService] S3 enabled but AWS_STORAGE_BUCKET_NAME not set', ); } this.s3Client = new S3Client({ region: process.env.AWS_S3_REGION_NAME, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, endpoint: process.env.AWS_S3_ENDPOINT_URL, }); } } /** * Upload a cover image with optional encryption. * Stores locally, optionally uploads to S3, and returns storage metadata. */ async uploadCoverImage( type: ImageType, file: Express.Multer.File, ): Promise { this.validateMimeType(file.mimetype); const key = this.buildStorageKey(type, file.originalname); const encryptedBuffer = encryptImageWithHeader(file.buffer); // Write to local filesystem await this.writeLocal(key, encryptedBuffer); // Optionally upload to S3 let s3Success = false; if (this.s3Enabled && this.s3Client && this.s3Bucket) { s3Success = await this.uploadToS3(key, encryptedBuffer, file.mimetype); } return { key, imgSource: s3Success ? 'S3_AND_LOCAL' : 'LOCAL_ONLY', }; } /** * Build canonical storage key: ///
/. */ private buildStorageKey(type: ImageType, originalName: string): string { const folder = type === 'ads-cover' ? 'ads-covers' : 'video-covers'; const now = new Date(); const yyyy = now.getFullYear(); const MM = String(now.getMonth() + 1).padStart(2, '0'); const dd = String(now.getDate()).padStart(2, '0'); const uuid = randomUUID(); const ext = path.extname(originalName).toLowerCase() || '.jpg'; return `${folder}/${yyyy}/${MM}/${dd}/${uuid}${ext}`; } /** * Validate that the file is an image. */ private validateMimeType(mimetype: string): void { const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (!allowed.includes(mimetype.toLowerCase())) { throw new BadRequestException( `Invalid image type: ${mimetype}. Allowed: ${allowed.join(', ')}`, ); } } /** * Write encrypted buffer to local filesystem. */ private async writeLocal(key: string, buffer: Uint8Array): Promise { const fullPath = path.join(this.localRoot, key); const dir = path.dirname(fullPath); try { await mkdir(dir, { recursive: true }); await writeFile(fullPath, buffer); this.logger.log( { key, path: fullPath }, '[ImageUploadService] Wrote local file', ); } catch (err) { this.logger.error( { err, key, path: fullPath }, '[ImageUploadService] Failed to write local file', ); throw new BadRequestException('Failed to save image locally'); } } /** * Upload encrypted buffer to S3. * Returns true on success, false on failure (logged, but non-blocking). */ private async uploadToS3( key: string, buffer: Uint8Array, mimetype: string, ): Promise { if (!this.s3Client || !this.s3Bucket) return false; try { const command = new PutObjectCommand({ Bucket: this.s3Bucket, Key: key, Body: buffer, ContentType: mimetype, }); await this.s3Client.send(command); this.logger.log( { key, bucket: this.s3Bucket }, '[ImageUploadService] Uploaded to S3', ); return true; } catch (err) { this.logger.error( { err, key, bucket: this.s3Bucket }, '[ImageUploadService] S3 upload failed', ); return false; } } }