| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- 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<UploadResult> {
- 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: <folder>/<yyyy>/<MM>/<dd>/<uuid>.<ext>
- */
- 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<void> {
- 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<boolean> {
- 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;
- }
- }
- }
|