Skip to content

Commit d92363d

Browse files
authored
feat: Add option to generate pre-signed URL with expiration time (#180)
1 parent 8311af9 commit d92363d

File tree

4 files changed

+180
-50
lines changed

4 files changed

+180
-50
lines changed

README.md

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ The preferred method is to use the default AWS credentials pattern. If no AWS c
7272
| Parameter | Optional | Default value | Environment variable | Description |
7373
|-----------|----------|---------------|----------------------|-------------|
7474
| `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. |
75+
| `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`. |
76+
| `presignedUrlExpires` | yes | `undefined` | 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. If no value is set, the AWS S3 SDK default [Expires](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property) value applies. This parameter requires `presignedUrl` to be `true`. |
7577

7678
### Using a config file
7779

@@ -93,6 +95,8 @@ The preferred method is to use the default AWS credentials pattern. If no AWS c
9395
"baseUrlDirect": false, // default value
9496
"signatureVersion": 'v4', // default value
9597
"globalCacheControl": null, // default value. Or 'public, max-age=86400' for 24 hrs Cache-Control
98+
"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.
99+
"presignedUrlExpires": null, // Optional. Sets the duration in seconds after which the presigned URL of the file expires. Defaults to the AWS S3 SDK default Expires value.
96100
"ServerSideEncryption": 'AES256|aws:kms', //AES256 or aws:kms, or if you do not pass this, encryption won't be done
97101
"validateFilename": null, // Default to parse-server FilesAdapter::validateFilename.
98102
"generateKey": null // Will default to Parse.FilesController.preserveFileName
@@ -132,29 +136,35 @@ And update your config / options
132136
```
133137
var S3Adapter = require('@parse/s3-files-adapter');
134138
135-
var s3Adapter = new S3Adapter('accessKey',
136-
'secretKey', bucket, {
137-
region: 'us-east-1'
138-
bucketPrefix: '',
139-
directAccess: false,
140-
baseUrl: 'http://images.example.com',
141-
signatureVersion: 'v4',
142-
globalCacheControl: 'public, max-age=86400', // 24 hrs Cache-Control.
143-
validateFilename: (filename) => {
144-
if (filename.length > 1024) {
145-
return 'Filename too long.';
146-
}
147-
return null; // Return null on success
148-
},
149-
generateKey: (filename) => {
150-
return `${Date.now()}_${filename}`; // unique prefix for every filename
151-
}
152-
});
139+
var s3Adapter = new S3Adapter(
140+
'accessKey',
141+
'secretKey',
142+
'bucket',
143+
{
144+
region: 'us-east-1'
145+
bucketPrefix: '',
146+
directAccess: false,
147+
baseUrl: 'http://images.example.com',
148+
signatureVersion: 'v4',
149+
globalCacheControl: 'public, max-age=86400', // 24 hrs Cache-Control.
150+
presignedUrl: false,
151+
presignedUrlExpires: 900,
152+
validateFilename: (filename) => {
153+
if (filename.length > 1024) {
154+
return 'Filename too long.';
155+
}
156+
return null; // Return null on success
157+
},
158+
generateKey: (filename) => {
159+
return `${Date.now()}_${filename}`; // unique prefix for every filename
160+
}
161+
}
162+
);
153163
154164
var api = new ParseServer({
155-
appId: 'my_app',
156-
masterKey: 'master_key',
157-
filesAdapter: s3adapter
165+
appId: 'my_app',
166+
masterKey: 'master_key',
167+
filesAdapter: s3adapter
158168
})
159169
```
160170
**Note:** there are a few ways you can pass arguments:
@@ -185,6 +195,8 @@ var s3Options = {
185195
"baseUrl": null // default value
186196
"signatureVersion": 'v4', // default value
187197
"globalCacheControl": null, // default value. Or 'public, max-age=86400' for 24 hrs Cache-Control
198+
"presignedUrl": false, // default value
199+
"presignedUrlExpires": 900, // default value (900 seconds)
188200
"validateFilename": () => null, // Anything goes!
189201
"generateKey": (filename) => filename, // Ensure Parse.FilesController.preserveFileName is true!
190202
}
@@ -211,6 +223,8 @@ var s3Options = {
211223
region: process.env.SPACES_REGION,
212224
directAccess: true,
213225
globalCacheControl: "public, max-age=31536000",
226+
presignedUrl: false,
227+
presignedUrlExpires: 900,
214228
bucketPrefix: process.env.SPACES_BUCKET_PREFIX,
215229
s3overrides: {
216230
accessKeyId: process.env.SPACES_ACCESS_KEY,

index.js

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ const serialize = (obj) => {
2121
return str.join('&');
2222
};
2323

24+
function buildDirectAccessUrl(baseUrl, baseUrlFileKey, presignedUrl, config, filename) {
25+
let directAccessUrl;
26+
if (typeof baseUrl === 'function') {
27+
directAccessUrl = `${baseUrl(config, filename)}/${baseUrlFileKey}`;
28+
} else {
29+
directAccessUrl = `${baseUrl}/${baseUrlFileKey}`;
30+
}
31+
32+
if (presignedUrl) {
33+
directAccessUrl += presignedUrl.substring(presignedUrl.indexOf('?'));
34+
}
35+
36+
return directAccessUrl;
37+
}
38+
2439
class S3Adapter {
2540
// Creates an S3 session.
2641
// Providing AWS access, secret keys and bucket are mandatory
@@ -36,6 +51,8 @@ class S3Adapter {
3651
this._baseUrlDirect = options.baseUrlDirect;
3752
this._signatureVersion = options.signatureVersion;
3853
this._globalCacheControl = options.globalCacheControl;
54+
this._presignedUrl = options.presignedUrl;
55+
this._presignedUrlExpires = parseInt(options.presignedUrlExpires, 10);
3956
this._encryption = options.ServerSideEncryption;
4057
this._generateKey = options.generateKey;
4158
// Optional FilesAdaptor method
@@ -158,22 +175,32 @@ class S3Adapter {
158175
// otherwise we serve the file through parse-server
159176
getFileLocation(config, filename) {
160177
const fileName = filename.split('/').map(encodeURIComponent).join('/');
161-
if (this._directAccess) {
162-
if (this._baseUrl) {
163-
if (typeof this._baseUrl === 'function') {
164-
if (this._baseUrlDirect) {
165-
return `${this._baseUrl(config, filename)}/${fileName}`;
166-
}
167-
return `${this._baseUrl(config, filename)}/${this._bucketPrefix + fileName}`;
168-
}
169-
if (this._baseUrlDirect) {
170-
return `${this._baseUrl}/${fileName}`;
171-
}
172-
return `${this._baseUrl}/${this._bucketPrefix + fileName}`;
178+
if (!this._directAccess) {
179+
return `${config.mount}/files/${config.applicationId}/${fileName}`;
180+
}
181+
182+
const fileKey = `${this._bucketPrefix}${fileName}`;
183+
184+
let presignedUrl = '';
185+
if (this._presignedUrl) {
186+
const params = { Bucket: this._bucket, Key: fileKey };
187+
if (this._presignedUrlExpires) {
188+
params.Expires = this._presignedUrlExpires;
189+
}
190+
// Always use the "getObject" operation, and we recommend that you protect the URL
191+
// appropriately: https://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURL.html
192+
presignedUrl = this._s3Client.getSignedUrl('getObject', params);
193+
if (!this._baseUrl) {
194+
return presignedUrl;
173195
}
174-
return `https://${this._bucket}.s3.amazonaws.com/${this._bucketPrefix + fileName}`;
175196
}
176-
return (`${config.mount}/files/${config.applicationId}/${fileName}`);
197+
198+
if (!this._baseUrl) {
199+
return `https://${this._bucket}.s3.amazonaws.com/${fileKey}`;
200+
}
201+
202+
const baseUrlFileKey = this._baseUrlDirect ? fileName : fileKey;
203+
return buildDirectAccessUrl(this._baseUrl, baseUrlFileKey, presignedUrl, config, filename);
177204
}
178205

179206
handleFileStream(filename, req, res) {

lib/optionsFromArguments.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const optionsFromArguments = function optionsFromArguments(args) {
6363
options.baseUrlDirect = otherOptions.baseUrlDirect;
6464
options.signatureVersion = otherOptions.signatureVersion;
6565
options.globalCacheControl = otherOptions.globalCacheControl;
66+
options.presignedUrl = otherOptions.presignedUrl;
67+
options.presignedUrlExpires = otherOptions.presignedUrlExpires;
6668
options.ServerSideEncryption = otherOptions.ServerSideEncryption;
6769
options.generateKey = otherOptions.generateKey;
6870
options.validateFilename = otherOptions.validateFilename;
@@ -93,6 +95,8 @@ const optionsFromArguments = function optionsFromArguments(args) {
9395
options = fromEnvironmentOrDefault(options, 'baseUrlDirect', 'S3_BASE_URL_DIRECT', false);
9496
options = fromEnvironmentOrDefault(options, 'signatureVersion', 'S3_SIGNATURE_VERSION', 'v4');
9597
options = fromEnvironmentOrDefault(options, 'globalCacheControl', 'S3_GLOBAL_CACHE_CONTROL', null);
98+
options = fromEnvironmentOrDefault(options, 'presignedUrl', 'S3_PRESIGNED_URL', false);
99+
options = fromEnvironmentOrDefault(options, 'presignedUrlExpires', 'S3_PRESIGNED_URL_EXPIRES', null);
96100
options = fromOptionsDictionaryOrDefault(options, 'generateKey', null);
97101
options = fromOptionsDictionaryOrDefault(options, 'validateFilename', null);
98102

spec/test.spec.js

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ describe('S3Adapter tests', () => {
116116
});
117117
});
118118

119+
119120
describe('should not throw when initialized properly', () => {
120121
it('should accept a string bucket', () => {
121122
expect(() => {
@@ -234,7 +235,7 @@ describe('S3Adapter tests', () => {
234235

235236
describe('getFileStream', () => {
236237
it('should handle range bytes', () => {
237-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket');
238+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket');
238239
s3._s3Client = {
239240
createBucket: (callback) => callback(),
240241
getObject: (params, callback) => {
@@ -265,7 +266,7 @@ describe('S3Adapter tests', () => {
265266
});
266267

267268
it('should handle range bytes error', () => {
268-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket');
269+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket');
269270
s3._s3Client = {
270271
createBucket: (callback) => callback(),
271272
getObject: (params, callback) => {
@@ -289,7 +290,7 @@ describe('S3Adapter tests', () => {
289290
});
290291

291292
it('should handle range bytes no data', () => {
292-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket');
293+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket');
293294
const data = { Error: 'NoBody' };
294295
s3._s3Client = {
295296
createBucket: (callback) => callback(),
@@ -330,26 +331,26 @@ describe('S3Adapter tests', () => {
330331
});
331332

332333
it('should get using the baseUrl', () => {
333-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
334+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
334335
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/foo/bar/test.png');
335336
});
336337

337338
it('should get direct to baseUrl', () => {
338339
options.baseUrlDirect = true;
339-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
340+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
340341
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png');
341342
});
342343

343344
it('should get without directAccess', () => {
344345
options.directAccess = false;
345-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
346+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
346347
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png');
347348
});
348349

349350
it('should go directly to amazon', () => {
350351
delete options.baseUrl;
351-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
352-
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://myBucket.s3.amazonaws.com/foo/bar/test.png');
352+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
353+
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://my-bucket.s3.amazonaws.com/foo/bar/test.png');
353354
});
354355
});
355356
describe('getFileLocation', () => {
@@ -373,26 +374,110 @@ describe('S3Adapter tests', () => {
373374
});
374375

375376
it('should get using the baseUrl', () => {
376-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
377+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
378+
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/foo/bar/test.png');
379+
});
380+
381+
it('should get direct to baseUrl', () => {
382+
options.baseUrlDirect = true;
383+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
384+
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png');
385+
});
386+
387+
it('should get without directAccess', () => {
388+
options.directAccess = false;
389+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
390+
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png');
391+
});
392+
393+
it('should go directly to amazon', () => {
394+
delete options.baseUrl;
395+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
396+
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://my-bucket.s3.amazonaws.com/foo/bar/test.png');
397+
});
398+
});
399+
describe('getFileLocation', () => {
400+
const testConfig = {
401+
mount: 'http://my.server.com/parse',
402+
applicationId: 'xxxx',
403+
};
404+
let options;
405+
406+
beforeEach(() => {
407+
options = {
408+
presignedUrl: false,
409+
directAccess: true,
410+
bucketPrefix: 'foo/bar/',
411+
baseUrl: 'http://example.com/files',
412+
};
413+
});
414+
415+
it('should get using the baseUrl', () => {
416+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
377417
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/foo/bar/test.png');
378418
});
379419

420+
it('when use presigned URL should use S3 \'getObject\' operation', () => {
421+
options.presignedUrl = true;
422+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
423+
const originalS3Client = s3._s3Client;
424+
let getSignedUrlOperation = '';
425+
s3._s3Client = {
426+
getSignedUrl: (operation, params, callback) => {
427+
getSignedUrlOperation = operation;
428+
return originalS3Client.getSignedUrl(operation, params, callback);
429+
},
430+
};
431+
432+
s3.getFileLocation(testConfig, 'test.png');
433+
expect(getSignedUrlOperation).toBe('getObject');
434+
});
435+
436+
it('should get using the baseUrl and amazon using presigned URL', () => {
437+
options.presignedUrl = true;
438+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
439+
440+
const fileLocation = s3.getFileLocation(testConfig, 'test.png');
441+
expect(fileLocation).toMatch(/^http:\/\/example.com\/files\/foo\/bar\/test.png\?/);
442+
expect(fileLocation).toMatch(/X-Amz-Credential=accessKey%2F\d{8}%2F\w{2}-\w{1,9}-\d%2Fs3%2Faws4_request/);
443+
expect(fileLocation).toMatch(/X-Amz-Date=\d{8}T\d{6}Z/);
444+
expect(fileLocation).toMatch(/X-Amz-Signature=.{64}/);
445+
expect(fileLocation).toMatch(/X-Amz-Expires=\d{1,6}/);
446+
expect(fileLocation).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
447+
expect(fileLocation).toContain('X-Amz-SignedHeaders=host');
448+
});
449+
380450
it('should get direct to baseUrl', () => {
381451
options.baseUrlDirect = true;
382-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
452+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
383453
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png');
384454
});
385455

386456
it('should get without directAccess', () => {
387457
options.directAccess = false;
388-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
458+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
389459
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png');
390460
});
391461

392462
it('should go directly to amazon', () => {
393463
delete options.baseUrl;
394-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
395-
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://myBucket.s3.amazonaws.com/foo/bar/test.png');
464+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
465+
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://my-bucket.s3.amazonaws.com/foo/bar/test.png');
466+
});
467+
468+
it('should go directly to amazon using presigned URL', () => {
469+
delete options.baseUrl;
470+
options.presignedUrl = true;
471+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
472+
473+
const fileLocation = s3.getFileLocation(testConfig, 'test.png');
474+
expect(fileLocation).toMatch(/^https:\/\/my-bucket.s3.amazonaws.com\/foo\/bar\/test.png\?/);
475+
expect(fileLocation).toMatch(/X-Amz-Credential=accessKey%2F\d{8}%2Fus-east-1%2Fs3%2Faws4_request/);
476+
expect(fileLocation).toMatch(/X-Amz-Date=\d{8}T\d{6}Z/);
477+
expect(fileLocation).toMatch(/X-Amz-Signature=.{64}/);
478+
expect(fileLocation).toMatch(/X-Amz-Expires=\d{1,6}/);
479+
expect(fileLocation).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
480+
expect(fileLocation).toContain('X-Amz-SignedHeaders=host');
396481
});
397482
});
398483

@@ -406,7 +491,7 @@ describe('S3Adapter tests', () => {
406491
});
407492

408493
it('should be null by default', () => {
409-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
494+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
410495
expect(s3.validateFilename === null).toBe(true);
411496
});
412497

@@ -420,7 +505,7 @@ describe('S3Adapter tests', () => {
420505
}
421506
return null;
422507
};
423-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
508+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
424509
expect(s3.validateFilename('foo/bar') instanceof Parse.Error).toBe(true);
425510
});
426511
});

0 commit comments

Comments
 (0)