|
|
@@ -1,165 +1,12 @@
|
|
|
-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';
|
|
|
+import { Injectable } from '@nestjs/common';
|
|
|
import type { MultipartFile } from '@fastify/multipart';
|
|
|
|
|
|
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 WITHOUT encryption for management API.
|
|
|
- * Management API serves to admins only, so encryption is not needed.
|
|
|
- * Stores locally, optionally uploads to S3, and returns storage metadata.
|
|
|
- */
|
|
|
- async uploadCoverImage(
|
|
|
- type: ImageType,
|
|
|
- file: MultipartFile,
|
|
|
- ): Promise<UploadResult> {
|
|
|
- this.validateMimeType(file.mimetype);
|
|
|
-
|
|
|
- const key = this.buildStorageKey(type, file.filename);
|
|
|
-
|
|
|
- // Convert stream to buffer
|
|
|
- const chunks: Buffer[] = [];
|
|
|
- for await (const chunk of file.file) {
|
|
|
- chunks.push(chunk);
|
|
|
- }
|
|
|
- const buffer = Buffer.concat(chunks);
|
|
|
-
|
|
|
- // NOTE: No encryption for management API - admins can view directly
|
|
|
- // const encryptedBuffer = encryptImageWithHeader(buffer);
|
|
|
-
|
|
|
- // Write to local filesystem (unencrypted)
|
|
|
- await this.writeLocal(key, buffer);
|
|
|
-
|
|
|
- // Optionally upload to S3 (also unencrypted for management API)
|
|
|
- let s3Success = false;
|
|
|
- if (this.s3Enabled && this.s3Client && this.s3Bucket) {
|
|
|
- s3Success = await this.uploadToS3(key, buffer, 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-cover' : 'video-cover';
|
|
|
- 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;
|
|
|
- }
|
|
|
+ async uploadCoverImage(type: ImageType, file: MultipartFile) {
|
|
|
+ // Replaced by libs/core/media-upload
|
|
|
+ throw new Error('NOT_IMPLEMENTED_MEDIA_UPLOAD');
|
|
|
}
|
|
|
}
|