diff --git a/index.js b/index.js index 9b94ddf..9deb8a8 100644 --- a/index.js +++ b/index.js @@ -140,16 +140,24 @@ class S3Adapter { // For a given config object, filename, and data, store a file in S3 // Returns a promise containing the S3 object creation response - async createFile(filename, data, contentType, options = {}) { + async createFile(filename, data, contentType, options = {}, config = {}) { + let key_without_prefix = filename; + if (typeof this._generateKey === 'function') { + const candidate = this._generateKey(filename, contentType, options); + key_without_prefix = + candidate && typeof candidate.then === 'function' ? await candidate : candidate; + if (typeof key_without_prefix !== 'string' || key_without_prefix.trim().length === 0) { + throw new Error('generateKey must return a non-empty string'); + } + key_without_prefix = key_without_prefix.trim(); + } + const params = { Bucket: this._bucket, - Key: this._bucketPrefix + filename, + Key: this._bucketPrefix + key_without_prefix, Body: data, }; - if (this._generateKey instanceof Function) { - params.Key = this._bucketPrefix + this._generateKey(filename); - } if (this._fileAcl) { if (this._fileAcl === 'none') { delete params.ACL; @@ -177,11 +185,39 @@ class S3Adapter { } await this.createBucket(); const command = new PutObjectCommand(params); - const response = await this._s3Client.send(command); - const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`; - const location = `${endpoint}/${params.Key}`; + await this._s3Client.send(command); - return Object.assign(response || {}, { Location: location }); + let locationBase; + if (this._endpoint) { + try { + const u = new URL(this._endpoint); + const origin = `${u.protocol}//${u.host}`; + const basePath = (u.pathname || '').replace(/\/$/, ''); + const hasBucketInHostOrPath = + u.hostname.startsWith(`${this._bucket}.`) || + basePath.split('/').includes(this._bucket); + const pathWithBucket = hasBucketInHostOrPath ? basePath : `${basePath}/${this._bucket}`; + locationBase = `${origin}${pathWithBucket}`; + } catch { + // Fallback for non-URL endpoints (assume hostname) + locationBase = `https://${String(this._endpoint).replace(/\/$/, '')}/${this._bucket}`; + } + } else { + const regionPart = this._region ? `.s3.${this._region}` : '.s3'; + locationBase = `https://${this._bucket}${regionPart}.amazonaws.com`; + } + const location = `${locationBase}/${params.Key}`; + + let url; + if (config?.mount && config?.applicationId) { // if config has required properties for getFileLocation + url = await this.getFileLocation(config, key_without_prefix); + } + + return { + location: location, // actual upload location, used for tests + name: key_without_prefix, // filename in storage, consistent with other adapters + ...url ? { url: url } : {} // url (optionally presigned) or non-direct access url + }; } async deleteFile(filename) { diff --git a/spec/test.spec.js b/spec/test.spec.js index 5402460..d37e5c2 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -729,7 +729,7 @@ describe('S3Adapter tests', () => { const s3 = getMockS3Adapter(options); const fileName = 'randomFileName.txt'; const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => { - const url = new URL(value.Location); + const url = new URL(value.location); expect(url.pathname.indexOf(fileName) > 13).toBe(true); }); promises.push(response); @@ -740,7 +740,7 @@ describe('S3Adapter tests', () => { const s3 = getMockS3Adapter(options); const fileName = 'foo/randomFileName.txt'; const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => { - const url = new URL(value.Location); + const url = new URL(value.location); expect(url.pathname.substring(1)).toEqual(options.bucketPrefix + fileName); }); promises.push(response); @@ -750,7 +750,7 @@ describe('S3Adapter tests', () => { const s3 = getMockS3Adapter(options); const fileName = 'foo/randomFileName.txt'; const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => { - const url = new URL(value.Location); + const url = new URL(value.location); expect(url.pathname.indexOf('foo/')).toEqual(6); expect(url.pathname.indexOf('random') > 13).toBe(true); }); @@ -867,6 +867,236 @@ describe('S3Adapter tests', () => { expect(commandArg).toBeInstanceOf(PutObjectCommand); expect(commandArg.input.ACL).toBeUndefined(); }); + + it('should return url when config is provided', async () => { + const options = { + bucket: 'bucket-1', + presignedUrl: true + }; + const s3 = new S3Adapter(options); + + const mockS3Response = { + ETag: '"mock-etag"', + VersionId: 'mock-version', + Location: 'mock-location' + }; + s3ClientMock.send.and.returnValue(Promise.resolve(mockS3Response)); + s3._s3Client = s3ClientMock; + + // Mock getFileLocation to return a presigned URL + spyOn(s3, 'getFileLocation').and.returnValue(Promise.resolve('https://presigned-url.com/file.txt')); + + const result = await s3.createFile( + 'file.txt', + 'hello world', + 'text/utf8', + {}, + { mount: 'http://example.com', applicationId: 'test123' } + ); + + expect(s3.getFileLocation).toHaveBeenCalledWith( + jasmine.objectContaining({ mount: 'http://example.com', applicationId: 'test123' }), + 'file.txt' + ); + expect(result).toEqual({ + location: jasmine.any(String), + name: 'file.txt', + url: 'https://presigned-url.com/file.txt' + }); + }); + + it('should handle generateKey function errors', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => { + throw new Error('Generate key failed'); + } + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('Generate key failed'); + }); + + it('should handle async generateKey function', async () => { + const options = { + bucket: 'bucket-1', + generateKey: async (filename) => { + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + return `async-${filename}`; + } + }; + const s3 = new S3Adapter(options); + s3ClientMock.send.and.returnValue(Promise.resolve({})); + s3._s3Client = s3ClientMock; + + const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); + + expect(s3ClientMock.send).toHaveBeenCalledTimes(2); + const commands = s3ClientMock.send.calls.all(); + const commandArg = commands[1].args[0]; + expect(commandArg).toBeInstanceOf(PutObjectCommand); + expect(commandArg.input.Key).toBe('async-file.txt'); + expect(out.name).toBe('async-file.txt'); + }); + + it('should handle generateKey that returns a Promise', async () => { + const options = { + bucket: 'bucket-1', + generateKey: (filename) => { + return Promise.resolve(`promise-${filename}`); + } + }; + const s3 = new S3Adapter(options); + s3ClientMock.send.and.returnValue(Promise.resolve({})); + s3._s3Client = s3ClientMock; + + const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); + + expect(s3ClientMock.send).toHaveBeenCalledTimes(2); + const commands = s3ClientMock.send.calls.all(); + const commandArg = commands[1].args[0]; + expect(commandArg).toBeInstanceOf(PutObjectCommand); + expect(commandArg.input.Key).toBe('promise-file.txt'); + expect(out.name).toBe('promise-file.txt'); + }); + + it('should validate generateKey returns a non-empty string', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => '' + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); + }); + + it('should validate generateKey returns a string (not number)', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => 12345 + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); + }); + + it('should reject when generateKey returns only whitespace', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => ' ' + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); + }); + + it('should validate async generateKey returns a string', async () => { + const options = { + bucket: 'bucket-1', + generateKey: async () => null + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); + }); + }); + + describe('URL construction with custom endpoints', () => { + let s3ClientMock; + + beforeEach(() => { + s3ClientMock = jasmine.createSpyObj('S3Client', ['send']); + s3ClientMock.send.and.returnValue(Promise.resolve({})); + }); + + it('should handle endpoint with path and query correctly', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'https://example.com:8080/path?foo=bar' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should construct proper URL without breaking query parameters + expect(result.location).toBe('https://example.com:8080/path/test-bucket/test.txt'); + }); + + it('should handle path-style endpoint without bucket in hostname', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'https://minio.example.com' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should add bucket to path for path-style + expect(result.location).toBe('https://minio.example.com/test-bucket/test.txt'); + }); + + it('should handle virtual-hosted-style endpoint with bucket in hostname', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'https://test-bucket.s3.example.com' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should not duplicate bucket when it's already in hostname + expect(result.location).toBe('https://test-bucket.s3.example.com/test.txt'); + }); + + it('should fallback for malformed endpoint', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'not-a-valid-url' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should fallback to safe construction + expect(result.location).toBe('https://not-a-valid-url/test-bucket/test.txt'); + }); + + it('should use default AWS endpoint when no custom endpoint', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + region: 'us-west-2' + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should use standard AWS S3 URL + expect(result.location).toBe('https://test-bucket.s3.us-west-2.amazonaws.com/test.txt'); + }); }); describe('handleFileStream', () => {