diff --git a/packages/core/manifest/src/config/storage.ts b/packages/core/manifest/src/config/storage.ts index 985ac9c77..15fe0057b 100644 --- a/packages/core/manifest/src/config/storage.ts +++ b/packages/core/manifest/src/config/storage.ts @@ -5,6 +5,7 @@ export default (): { s3Region: string s3AccessKeyId: string s3SecretAccessKey: string + s3Provider: string } } => { return { @@ -13,7 +14,8 @@ export default (): { s3Endpoint: process.env.S3_ENDPOINT, s3Region: process.env.S3_REGION, s3AccessKeyId: process.env.S3_ACCESS_KEY_ID, - s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY + s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + s3Provider: process.env.S3_PROVIDER, } } } diff --git a/packages/core/manifest/src/storage/services/storage.service.ts b/packages/core/manifest/src/storage/services/storage.service.ts index 50512c827..c13ce40ff 100644 --- a/packages/core/manifest/src/storage/services/storage.service.ts +++ b/packages/core/manifest/src/storage/services/storage.service.ts @@ -30,21 +30,27 @@ export class StorageService { this.s3Region = this.configService.get('storage.s3Region') this.s3AccessKeyId = this.configService.get('storage.s3AccessKeyId') this.s3SecretAccessKey = this.configService.get('storage.s3SecretAccessKey') - this.s3provider = this.s3Endpoint?.includes('amazon') - ? 'aws' - : this.s3Endpoint?.includes('digitalocean') - ? 'digitalocean' - : 'other' + this.s3provider = this.configService.get('storage.s3Provider') + ? this.configService.get('storage.s3Provider') + : this.s3Endpoint?.includes('amazon') + ? 'aws' + : this.s3Endpoint?.includes('digitalocean') + ? 'digitalocean' + : 'other' if (this.isS3Enabled) { + let s3Endpoint = this.configService.get('storage.s3Endpoint') + if (this.s3provider === 'supabase') { + s3Endpoint = this.s3Endpoint + '/s3' + } this.s3Client = new S3Client({ region: this.s3Region, - endpoint: this.s3Endpoint, + endpoint: s3Endpoint, credentials: { accessKeyId: this.s3AccessKeyId, secretAccessKey: this.s3SecretAccessKey }, - forcePathStyle: false + forcePathStyle: this.s3provider === 'supabase' }) } } @@ -193,6 +199,9 @@ export class StorageService { * @returns The S3 file URL. */ private async uploadToS3(key: string, buffer: Buffer): Promise { + if (this.s3provider === 'supabase') { + return this.uploadToSupabase(key, buffer) + } await this.s3Client.send( new PutObjectCommand({ Bucket: this.s3Bucket, @@ -204,4 +213,16 @@ export class StorageService { ) return `${this.s3Endpoint}/${this.s3Bucket}/${STORAGE_PATH}/${key}` } + + private async uploadToSupabase(key: string, buffer: Buffer): Promise { + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.s3Bucket, + Key: `${STORAGE_PATH}/${key}`, + Body: buffer, + }) + ) + + return `${this.s3Endpoint}/object/public/${this.s3Bucket}/${STORAGE_PATH}/${key}`; + } } diff --git a/packages/core/manifest/src/storage/tests/storage.service.spec.ts b/packages/core/manifest/src/storage/tests/storage.service.spec.ts index d2147c7d2..295506374 100644 --- a/packages/core/manifest/src/storage/tests/storage.service.spec.ts +++ b/packages/core/manifest/src/storage/tests/storage.service.spec.ts @@ -3,6 +3,8 @@ import { DEFAULT_IMAGE_SIZES } from '../../constants' import { ImageSizesObject } from '../../../../types/src' import { ConfigService } from '@nestjs/config' import { StorageService } from '../services/storage.service' +import { S3Client, PutObjectCommand, PutObjectCommandOutput } from '@aws-sdk/client-s3' +import { Command } from '@smithy/smithy-client'; const fs = require('fs') const mkdirp = require('mkdirp') @@ -192,4 +194,103 @@ describe('StorageService', () => { expect(filePaths.huge).toBe('s3-image-url') }) }) + + describe('Supabase storage', () => { + beforeEach(() => { + file.originalname = 'file.jpg' + image.originalname = 'test.jpg' + + jest.clearAllMocks() + + jest.spyOn(configService, 'get').mockImplementation((key: string) => { + switch (key) { + case 'storage.s3Bucket': + return 'test-supabase-bucket' + case 'storage.s3Endpoint': + return 'https://xyz.supabase.co' + case 'storage.s3Region': + return 'us-west-1' + case 'storage.s3AccessKeyId': + return 'test-supabase-access-key-id' + case 'storage.s3SecretAccessKey': + return 'test-supabase-secret-access-key' + case 'storage.s3Provider': + return 'supabase' + default: + return null + } + }) + + // Mock S3Client and its send method + jest.spyOn(S3Client.prototype, 'send').mockImplementation( + async ( + command: Command + ): Promise => { + // Check if the command is PutObjectCommand and return a mock PutObjectCommandOutput + if (command instanceof PutObjectCommand) { + return Promise.resolve({ + $metadata: { httpStatusCode: 200 }, + ETag: 'mock-etag', + } as PutObjectCommandOutput as OutputType); + } + // Return a generic metadata object for unhandled commands + return Promise.resolve({ $metadata: {} } as OutputType); + } + ); + + service = new StorageService(configService) + }) + + it('should initialize the S3 client for Supabase', () => { + expect(service['s3Client']).toBeDefined() + // Check if forcePathStyle is true for Supabase + expect(service['s3Client'].config.forcePathStyle).toBe(true) + }) + + it('should upload a file to Supabase', async () => { + const sendSpy = jest.spyOn(S3Client.prototype, 'send') + + const filePath = await service.store(entity, property, file) + expect(sendSpy).toHaveBeenCalledWith(expect.any(PutObjectCommand)) + // Expect Supabase URL format with date folder and unique ID + expect(filePath).toMatch( + new RegExp( + `^https://xyz\\.supabase\\.co/object/public/test-supabase-bucket/storage/${entity}/${property}/[A-Za-z]{3}\\d{4}/[a-z0-9]+-file\\.jpg$` + ) + ) + }) + + it('should upload an image to Supabase', async () => { + const sendSpy = jest.spyOn(S3Client.prototype, 'send') + + const imageSizes: ImageSizesObject = { + tiny: { + height: 100 + }, + huge: { + width: 1000 + } + } + + const filePaths = await service.storeImage( + entity, + property, + image, + imageSizes + ) + // Expect 2 calls, one for each size + expect(sendSpy).toHaveBeenCalledTimes(2) + // Expect Supabase URL format with date folder and unique ID + expect(filePaths.tiny).toMatch( + new RegExp( + `^https://xyz\\.supabase\\.co/object/public/test-supabase-bucket/storage/${entity}/${property}/[A-Za-z]{3}\\d{4}/[a-z0-9]+-tiny\\.jpg$` + ) + ) + expect(filePaths.huge).toMatch( + new RegExp( + `^https://xyz\\.supabase\\.co/object/public/test-supabase-bucket/storage/${entity}/${property}/[A-Za-z]{3}\\d{4}/[a-z0-9]+-huge\\.jpg$` + ) + ) + }) + }) })