image-upload.service.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { Injectable, BadRequestException } from '@nestjs/common';
  2. import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
  3. import { Logger } from 'nestjs-pino';
  4. import { mkdir, writeFile } from 'fs/promises';
  5. import { randomUUID } from 'crypto';
  6. import * as path from 'path';
  7. import { encryptImageWithHeader } from '@box/common/utils/image-lib';
  8. type ImageType = 'ads-cover' | 'video-cover';
  9. interface UploadResult {
  10. key: string;
  11. imgSource: 'LOCAL_ONLY' | 'S3_AND_LOCAL';
  12. }
  13. @Injectable()
  14. export class ImageUploadService {
  15. private readonly s3Client?: S3Client;
  16. private readonly localRoot: string;
  17. private readonly s3Enabled: boolean;
  18. private readonly s3Bucket?: string;
  19. constructor(private readonly logger: Logger) {
  20. this.localRoot = process.env.BOX_IMAGE_LOCAL_ROOT || '/tmp/box-images';
  21. this.s3Enabled = process.env.BOX_IMAGE_S3_ENABLED === 'true';
  22. if (this.s3Enabled) {
  23. this.s3Bucket = process.env.AWS_STORAGE_BUCKET_NAME;
  24. if (!this.s3Bucket) {
  25. this.logger.warn(
  26. '[ImageUploadService] S3 enabled but AWS_STORAGE_BUCKET_NAME not set',
  27. );
  28. }
  29. this.s3Client = new S3Client({
  30. region: process.env.AWS_S3_REGION_NAME,
  31. credentials: {
  32. accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
  33. secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  34. },
  35. endpoint: process.env.AWS_S3_ENDPOINT_URL,
  36. });
  37. }
  38. }
  39. /**
  40. * Upload a cover image with optional encryption.
  41. * Stores locally, optionally uploads to S3, and returns storage metadata.
  42. */
  43. async uploadCoverImage(
  44. type: ImageType,
  45. file: Express.Multer.File,
  46. ): Promise<UploadResult> {
  47. this.validateMimeType(file.mimetype);
  48. const key = this.buildStorageKey(type, file.originalname);
  49. const encryptedBuffer = encryptImageWithHeader(file.buffer);
  50. // Write to local filesystem
  51. await this.writeLocal(key, encryptedBuffer);
  52. // Optionally upload to S3
  53. let s3Success = false;
  54. if (this.s3Enabled && this.s3Client && this.s3Bucket) {
  55. s3Success = await this.uploadToS3(key, encryptedBuffer, file.mimetype);
  56. }
  57. return {
  58. key,
  59. imgSource: s3Success ? 'S3_AND_LOCAL' : 'LOCAL_ONLY',
  60. };
  61. }
  62. /**
  63. * Build canonical storage key: <folder>/<yyyy>/<MM>/<dd>/<uuid>.<ext>
  64. */
  65. private buildStorageKey(type: ImageType, originalName: string): string {
  66. const folder = type === 'ads-cover' ? 'ads-covers' : 'video-covers';
  67. const now = new Date();
  68. const yyyy = now.getFullYear();
  69. const MM = String(now.getMonth() + 1).padStart(2, '0');
  70. const dd = String(now.getDate()).padStart(2, '0');
  71. const uuid = randomUUID();
  72. const ext = path.extname(originalName).toLowerCase() || '.jpg';
  73. return `${folder}/${yyyy}/${MM}/${dd}/${uuid}${ext}`;
  74. }
  75. /**
  76. * Validate that the file is an image.
  77. */
  78. private validateMimeType(mimetype: string): void {
  79. const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
  80. if (!allowed.includes(mimetype.toLowerCase())) {
  81. throw new BadRequestException(
  82. `Invalid image type: ${mimetype}. Allowed: ${allowed.join(', ')}`,
  83. );
  84. }
  85. }
  86. /**
  87. * Write encrypted buffer to local filesystem.
  88. */
  89. private async writeLocal(key: string, buffer: Uint8Array): Promise<void> {
  90. const fullPath = path.join(this.localRoot, key);
  91. const dir = path.dirname(fullPath);
  92. try {
  93. await mkdir(dir, { recursive: true });
  94. await writeFile(fullPath, buffer);
  95. this.logger.log(
  96. { key, path: fullPath },
  97. '[ImageUploadService] Wrote local file',
  98. );
  99. } catch (err) {
  100. this.logger.error(
  101. { err, key, path: fullPath },
  102. '[ImageUploadService] Failed to write local file',
  103. );
  104. throw new BadRequestException('Failed to save image locally');
  105. }
  106. }
  107. /**
  108. * Upload encrypted buffer to S3.
  109. * Returns true on success, false on failure (logged, but non-blocking).
  110. */
  111. private async uploadToS3(
  112. key: string,
  113. buffer: Uint8Array,
  114. mimetype: string,
  115. ): Promise<boolean> {
  116. if (!this.s3Client || !this.s3Bucket) return false;
  117. try {
  118. const command = new PutObjectCommand({
  119. Bucket: this.s3Bucket,
  120. Key: key,
  121. Body: buffer,
  122. ContentType: mimetype,
  123. });
  124. await this.s3Client.send(command);
  125. this.logger.log(
  126. { key, bucket: this.s3Bucket },
  127. '[ImageUploadService] Uploaded to S3',
  128. );
  129. return true;
  130. } catch (err) {
  131. this.logger.error(
  132. { err, key, bucket: this.s3Bucket },
  133. '[ImageUploadService] S3 upload failed',
  134. );
  135. return false;
  136. }
  137. }
  138. }