|
|
@@ -0,0 +1,154 @@
|
|
|
+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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|