diff --git a/README.md b/README.md index 6d98fd4..4eb3017 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ The preferred method is to use the default AWS credentials pattern. If no AWS c | Parameter | Optional | Default value | Environment variable | Description | |-----------|----------|---------------|----------------------|-------------| | `fileAcl` | yes | `undefined` | S3_FILE_ACL | Sets the [Canned ACL](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl) of the file when storing it in the S3 bucket. Setting this parameter overrides the file ACL that would otherwise depend on the `directAccess` parameter. Setting the value `'none'` causes any ACL parameter to be removed that would otherwise be set. | +| `presignedUrl` | yes | `false` | S3_PRESIGNED_URL | If `true` a [presigned URL](https://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURL.html) is returned when requesting the URL of file. The URL is only valid for a specified duration, see parameter `presignedUrlExpires`. | +| `presignedUrlExpires` | yes | `900` | S3_PRESIGNED_URL_EXPIRES | Sets the duration in seconds after which the [presigned URL](https://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURL.html) of the file expires. This parameter requires `presignedUrl` to be `true`. | ### Using a config file @@ -75,6 +77,8 @@ The preferred method is to use the default AWS credentials pattern. If no AWS c "baseUrlDirect": false, // default value "signatureVersion": 'v4', // default value "globalCacheControl": null, // default value. Or 'public, max-age=86400' for 24 hrs Cache-Control + "presignedUrl": false, // Optional. If true a presigned URL is returned when requesting the URL of file. The URL is only valid for a specified duration, see parameter `presignedUrlExpires`. Default is false. + "presignedUrlExpires": 900, // Optional. Sets the duration in seconds after which the presigned URL of the file expires. Default is 900 seconds. "ServerSideEncryption": 'AES256|aws:kms', //AES256 or aws:kms, or if you do not pass this, encryption won't be done "validateFilename": null, // Default to parse-server FilesAdapter::validateFilename. "generateKey": null // Will default to Parse.FilesController.preserveFileName @@ -114,29 +118,35 @@ And update your config / options ``` var S3Adapter = require('@parse/s3-files-adapter'); -var s3Adapter = new S3Adapter('accessKey', - 'secretKey', bucket, { - region: 'us-east-1' - bucketPrefix: '', - directAccess: false, - baseUrl: 'http://images.example.com', - signatureVersion: 'v4', - globalCacheControl: 'public, max-age=86400', // 24 hrs Cache-Control. - validateFilename: (filename) => { - if (filename.length > 1024) { - return 'Filename too long.'; - } - return null; // Return null on success - }, - generateKey: (filename) => { - return `${Date.now()}_${filename}`; // unique prefix for every filename - } - }); +var s3Adapter = new S3Adapter( + 'accessKey', + 'secretKey', + 'bucket', + { + region: 'us-east-1' + bucketPrefix: '', + directAccess: false, + baseUrl: 'http://images.example.com', + signatureVersion: 'v4', + globalCacheControl: 'public, max-age=86400', // 24 hrs Cache-Control. + presignedUrl: false, + presignedUrlExpires: 900, + validateFilename: (filename) => { + if (filename.length > 1024) { + return 'Filename too long.'; + } + return null; // Return null on success + }, + generateKey: (filename) => { + return `${Date.now()}_${filename}`; // unique prefix for every filename + } + } +); var api = new ParseServer({ - appId: 'my_app', - masterKey: 'master_key', - filesAdapter: s3adapter + appId: 'my_app', + masterKey: 'master_key', + filesAdapter: s3adapter }) ``` **Note:** there are a few ways you can pass arguments: @@ -167,6 +177,8 @@ var s3Options = { "baseUrl": null // default value "signatureVersion": 'v4', // default value "globalCacheControl": null, // default value. Or 'public, max-age=86400' for 24 hrs Cache-Control + "presignedUrl": false, // default value + "presignedUrlExpires": 900, // default value (900 seconds) "validateFilename": () => null, // Anything goes! "generateKey": (filename) => filename, // Ensure Parse.FilesController.preserveFileName is true! } @@ -193,6 +205,8 @@ var s3Options = { region: process.env.SPACES_REGION, directAccess: true, globalCacheControl: "public, max-age=31536000", + presignedUrl: false, + presignedUrlExpires: 900, bucketPrefix: process.env.SPACES_BUCKET_PREFIX, s3overrides: { accessKeyId: process.env.SPACES_ACCESS_KEY, diff --git a/index.js b/index.js index d5b3196..ddaac93 100644 --- a/index.js +++ b/index.js @@ -21,6 +21,21 @@ const serialize = (obj) => { return str.join('&'); }; +function buildDirectAccessUrl(baseUrl, baseUrlFileKey, presignedUrl, config, filename) { + let directAccessUrl; + if (typeof baseUrl === 'function') { + directAccessUrl = `${baseUrl(config, filename)}/${baseUrlFileKey}`; + } else { + directAccessUrl = `${baseUrl}/${baseUrlFileKey}`; + } + + if (presignedUrl) { + directAccessUrl += presignedUrl.substring(presignedUrl.indexOf('?')); + } + + return directAccessUrl; +} + class S3Adapter { // Creates an S3 session. // Providing AWS access, secret keys and bucket are mandatory @@ -36,6 +51,8 @@ class S3Adapter { this._baseUrlDirect = options.baseUrlDirect; this._signatureVersion = options.signatureVersion; this._globalCacheControl = options.globalCacheControl; + this._presignedUrl = options.presignedUrl; + this._presignedUrlExpires = parseInt(options.presignedUrlExpires, 10); this._encryption = options.ServerSideEncryption; this._generateKey = options.generateKey; // Optional FilesAdaptor method @@ -158,22 +175,29 @@ class S3Adapter { // otherwise we serve the file through parse-server getFileLocation(config, filename) { const fileName = filename.split('/').map(encodeURIComponent).join('/'); - if (this._directAccess) { - if (this._baseUrl) { - if (typeof this._baseUrl === 'function') { - if (this._baseUrlDirect) { - return `${this._baseUrl(config, filename)}/${fileName}`; - } - return `${this._baseUrl(config, filename)}/${this._bucketPrefix + fileName}`; - } - if (this._baseUrlDirect) { - return `${this._baseUrl}/${fileName}`; - } - return `${this._baseUrl}/${this._bucketPrefix + fileName}`; + if (!this._directAccess) { + return `${config.mount}/files/${config.applicationId}/${fileName}`; + } + + const fileKey = `${this._bucketPrefix}${fileName}`; + + let presignedUrl = ''; + if (this._presignedUrl) { + const params = { Bucket: this._bucket, Key: fileKey, Expires: this._presignedUrlExpires }; + // Always use the "getObject" operation, and we recommend that you protect the URL + // appropriately: https://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURL.html + presignedUrl = this._s3Client.getSignedUrl('getObject', params); + if (!this._baseUrl) { + return presignedUrl; } - return `https://${this._bucket}.s3.amazonaws.com/${this._bucketPrefix + fileName}`; } - return (`${config.mount}/files/${config.applicationId}/${fileName}`); + + if (!this._baseUrl) { + return `https://${this._bucket}.s3.amazonaws.com/${fileKey}`; + } + + const baseUrlFileKey = this._baseUrlDirect ? fileName : fileKey; + return buildDirectAccessUrl(this._baseUrl, baseUrlFileKey, presignedUrl, config, filename); } handleFileStream(filename, req, res) { diff --git a/lib/optionsFromArguments.js b/lib/optionsFromArguments.js index 4de8c19..3430c19 100644 --- a/lib/optionsFromArguments.js +++ b/lib/optionsFromArguments.js @@ -64,6 +64,8 @@ const optionsFromArguments = function optionsFromArguments(args) { options.baseUrlDirect = otherOptions.baseUrlDirect; options.signatureVersion = otherOptions.signatureVersion; options.globalCacheControl = otherOptions.globalCacheControl; + options.presignedUrl = otherOptions.presignedUrl; + options.presignedUrlExpires = otherOptions.presignedUrlExpires; options.ServerSideEncryption = otherOptions.ServerSideEncryption; options.generateKey = otherOptions.generateKey; options.validateFilename = otherOptions.validateFilename; @@ -94,6 +96,8 @@ const optionsFromArguments = function optionsFromArguments(args) { options = fromEnvironmentOrDefault(options, 'baseUrlDirect', 'S3_BASE_URL_DIRECT', false); options = fromEnvironmentOrDefault(options, 'signatureVersion', 'S3_SIGNATURE_VERSION', 'v4'); options = fromEnvironmentOrDefault(options, 'globalCacheControl', 'S3_GLOBAL_CACHE_CONTROL', null); + options = fromEnvironmentOrDefault(options, 'presignedUrl', 'S3_PRESIGNED_URL', false); + options = fromEnvironmentOrDefault(options, 'presignedUrlExpires', 'S3_PRESIGNED_URL_EXPIRES', 900); options = fromOptionsDictionaryOrDefault(options, 'generateKey', null); options = fromOptionsDictionaryOrDefault(options, 'validateFilename', null); diff --git a/spec/test.spec.js b/spec/test.spec.js index 3ce6e88..fca0d42 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -235,7 +235,7 @@ describe('S3Adapter tests', () => { describe('getFileStream', () => { it('should handle range bytes', () => { - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket'); + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket'); s3._s3Client = { createBucket: (callback) => callback(), getObject: (params, callback) => { @@ -266,7 +266,7 @@ describe('S3Adapter tests', () => { }); it('should handle range bytes error', () => { - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket'); + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket'); s3._s3Client = { createBucket: (callback) => callback(), getObject: (params, callback) => { @@ -290,7 +290,7 @@ describe('S3Adapter tests', () => { }); it('should handle range bytes no data', () => { - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket'); + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket'); const data = { Error: 'NoBody' }; s3._s3Client = { createBucket: (callback) => callback(), @@ -316,84 +316,89 @@ describe('S3Adapter tests', () => { }); describe('getFileLocation', () => { - const testConfig = { - mount: 'http://my.server.com/parse', - applicationId: 'xxxx', - }; - let options; - - beforeEach(() => { - options = { - directAccess: true, - bucketPrefix: 'foo/bar/', - baseUrl: 'http://example.com/files', + ['http://example.com/files', () => 'http://example.com/files'].forEach((baseUrl) => { + const testConfig = { + mount: 'http://my.server.com/parse', + applicationId: 'xxxx', }; - }); - - it('should get using the baseUrl', () => { - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/foo/bar/test.png'); - }); + let options; + + beforeEach(() => { + options = { + presignedUrl: false, + directAccess: true, + bucketPrefix: 'foo/bar/', + baseUrl, + }; + }); - it('should get direct to baseUrl', () => { - options.baseUrlDirect = true; - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png'); - }); + it('should get using the baseUrl', () => { + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); + expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/foo/bar/test.png'); + }); - it('should get without directAccess', () => { - options.directAccess = false; - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png'); - }); + it('when use presigned URL should use S3 \'getObject\' operation', () => { + options.presignedUrl = true; + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); + const originalS3Client = s3._s3Client; + let getSignedUrlOperation = ''; + s3._s3Client = { + getSignedUrl: (operation, params, callback) => { + getSignedUrlOperation = operation; + return originalS3Client.getSignedUrl(operation, params, callback); + }, + }; - it('should go directly to amazon', () => { - delete options.baseUrl; - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://myBucket.s3.amazonaws.com/foo/bar/test.png'); - }); - }); - describe('getFileLocation', () => { - const testConfig = { - mount: 'http://my.server.com/parse', - applicationId: 'xxxx', - }; - let options; + s3.getFileLocation(testConfig, 'test.png'); + expect(getSignedUrlOperation).toBe('getObject'); + }); - beforeEach(() => { - options = { - directAccess: true, - bucketPrefix: 'foo/bar/', - baseUrl: (fileconfig, filename) => { - if (filename.length > 12) { - return 'http://example.com/files'; - } - return 'http://example.com/files'; - }, - }; - }); + it('should get using the baseUrl and amazon using presigned URL', () => { + options.presignedUrl = true; + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); + + const fileLocation = s3.getFileLocation(testConfig, 'test.png'); + expect(fileLocation).toMatch(/^http:\/\/example.com\/files\/foo\/bar\/test.png\?/); + expect(fileLocation).toMatch(/X-Amz-Credential=accessKey%2F\d{8}%2F\w{2}-\w{1,9}-\d%2Fs3%2Faws4_request/); + expect(fileLocation).toMatch(/X-Amz-Date=\d{8}T\d{6}Z/); + expect(fileLocation).toMatch(/X-Amz-Signature=.{64}/); + expect(fileLocation).toMatch(/X-Amz-Expires=\d{1,6}/); + expect(fileLocation).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256'); + expect(fileLocation).toContain('X-Amz-SignedHeaders=host'); + }); - it('should get using the baseUrl', () => { - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/foo/bar/test.png'); - }); + it('should get direct to baseUrl', () => { + options.baseUrlDirect = true; + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); + expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png'); + }); - it('should get direct to baseUrl', () => { - options.baseUrlDirect = true; - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png'); - }); + it('should get without directAccess', () => { + options.directAccess = false; + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); + expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png'); + }); - it('should get without directAccess', () => { - options.directAccess = false; - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png'); - }); + it('should go directly to amazon', () => { + delete options.baseUrl; + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); + expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://my-bucket.s3.amazonaws.com/foo/bar/test.png'); + }); - it('should go directly to amazon', () => { - delete options.baseUrl; - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); - expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://myBucket.s3.amazonaws.com/foo/bar/test.png'); + it('should go directly to amazon using presigned URL', () => { + delete options.baseUrl; + options.presignedUrl = true; + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); + + const fileLocation = s3.getFileLocation(testConfig, 'test.png'); + expect(fileLocation).toMatch(/^https:\/\/my-bucket.s3.amazonaws.com\/foo\/bar\/test.png\?/); + expect(fileLocation).toMatch(/X-Amz-Credential=accessKey%2F\d{8}%2Fus-east-1%2Fs3%2Faws4_request/); + expect(fileLocation).toMatch(/X-Amz-Date=\d{8}T\d{6}Z/); + expect(fileLocation).toMatch(/X-Amz-Signature=.{64}/); + expect(fileLocation).toMatch(/X-Amz-Expires=\d{1,6}/); + expect(fileLocation).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256'); + expect(fileLocation).toContain('X-Amz-SignedHeaders=host'); + }); }); }); @@ -407,7 +412,7 @@ describe('S3Adapter tests', () => { }); it('should be null by default', () => { - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); expect(s3.validateFilename === null).toBe(true); }); @@ -421,7 +426,7 @@ describe('S3Adapter tests', () => { } return null; }; - const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options); + const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options); expect(s3.validateFilename('foo/bar') instanceof Parse.Error).toBe(true); }); });