Skip to content

Commit fadb515

Browse files
authored
Merge 04bf89d into 8311af9
2 parents 8311af9 + 04bf89d commit fadb515

File tree

4 files changed

+127
-82
lines changed

4 files changed

+127
-82
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 | `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`. |
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": 900, // Optional. Sets the duration in seconds after which the presigned URL of the file expires. Default is 900 seconds.
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: 38 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,29 @@ 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, Expires: this._presignedUrlExpires };
187+
// Always use the "getObject" operation, and we recommend that you protect the URL
188+
// appropriately: https://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURL.html
189+
presignedUrl = this._s3Client.getSignedUrl('getObject', params);
190+
if (!this._baseUrl) {
191+
return presignedUrl;
173192
}
174-
return `https://${this._bucket}.s3.amazonaws.com/${this._bucketPrefix + fileName}`;
175193
}
176-
return (`${config.mount}/files/${config.applicationId}/${fileName}`);
194+
195+
if (!this._baseUrl) {
196+
return `https://${this._bucket}.s3.amazonaws.com/${fileKey}`;
197+
}
198+
199+
const baseUrlFileKey = this._baseUrlDirect ? fileName : fileKey;
200+
return buildDirectAccessUrl(this._baseUrl, baseUrlFileKey, presignedUrl, config, filename);
177201
}
178202

179203
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', 900);
96100
options = fromOptionsDictionaryOrDefault(options, 'generateKey', null);
97101
options = fromOptionsDictionaryOrDefault(options, 'validateFilename', null);
98102

spec/test.spec.js

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ describe('S3Adapter tests', () => {
234234

235235
describe('getFileStream', () => {
236236
it('should handle range bytes', () => {
237-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket');
237+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket');
238238
s3._s3Client = {
239239
createBucket: (callback) => callback(),
240240
getObject: (params, callback) => {
@@ -265,7 +265,7 @@ describe('S3Adapter tests', () => {
265265
});
266266

267267
it('should handle range bytes error', () => {
268-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket');
268+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket');
269269
s3._s3Client = {
270270
createBucket: (callback) => callback(),
271271
getObject: (params, callback) => {
@@ -289,7 +289,7 @@ describe('S3Adapter tests', () => {
289289
});
290290

291291
it('should handle range bytes no data', () => {
292-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket');
292+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket');
293293
const data = { Error: 'NoBody' };
294294
s3._s3Client = {
295295
createBucket: (callback) => callback(),
@@ -323,76 +323,79 @@ describe('S3Adapter tests', () => {
323323

324324
beforeEach(() => {
325325
options = {
326+
presignedUrl: false,
326327
directAccess: true,
327328
bucketPrefix: 'foo/bar/',
328329
baseUrl: 'http://example.com/files',
329330
};
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

337-
it('should get direct to baseUrl', () => {
338-
options.baseUrlDirect = true;
339-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
340-
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png');
341-
});
342-
343-
it('should get without directAccess', () => {
344-
options.directAccess = false;
345-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
346-
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png');
347-
});
348-
349-
it('should go directly to amazon', () => {
350-
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');
353-
});
354-
});
355-
describe('getFileLocation', () => {
356-
const testConfig = {
357-
mount: 'http://my.server.com/parse',
358-
applicationId: 'xxxx',
359-
};
360-
let options;
361-
362-
beforeEach(() => {
363-
options = {
364-
directAccess: true,
365-
bucketPrefix: 'foo/bar/',
366-
baseUrl: (fileconfig, filename) => {
367-
if (filename.length > 12) {
368-
return 'http://example.com/files';
369-
}
370-
return 'http://example.com/files';
338+
it('when use presigned URL should use S3 \'getObject\' operation', () => {
339+
options.presignedUrl = true;
340+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
341+
const originalS3Client = s3._s3Client;
342+
let getSignedUrlOperation = '';
343+
s3._s3Client = {
344+
getSignedUrl: (operation, params, callback) => {
345+
getSignedUrlOperation = operation;
346+
return originalS3Client.getSignedUrl(operation, params, callback);
371347
},
372348
};
349+
350+
s3.getFileLocation(testConfig, 'test.png');
351+
expect(getSignedUrlOperation).toBe('getObject');
373352
});
374353

375-
it('should get using the baseUrl', () => {
376-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
377-
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/foo/bar/test.png');
354+
it('should get using the baseUrl and amazon using presigned URL', () => {
355+
options.presignedUrl = true;
356+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
357+
358+
const fileLocation = s3.getFileLocation(testConfig, 'test.png');
359+
expect(fileLocation).toMatch(/^http:\/\/example.com\/files\/foo\/bar\/test.png\?/);
360+
expect(fileLocation).toMatch(/X-Amz-Credential=accessKey%2F\d{8}%2F\w{2}-\w{1,9}-\d%2Fs3%2Faws4_request/);
361+
expect(fileLocation).toMatch(/X-Amz-Date=\d{8}T\d{6}Z/);
362+
expect(fileLocation).toMatch(/X-Amz-Signature=.{64}/);
363+
expect(fileLocation).toMatch(/X-Amz-Expires=\d{1,6}/);
364+
expect(fileLocation).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
365+
expect(fileLocation).toContain('X-Amz-SignedHeaders=host');
378366
});
379367

380368
it('should get direct to baseUrl', () => {
381369
options.baseUrlDirect = true;
382-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
370+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
383371
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://example.com/files/test.png');
384372
});
385373

386374
it('should get without directAccess', () => {
387375
options.directAccess = false;
388-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
376+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
389377
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('http://my.server.com/parse/files/xxxx/test.png');
390378
});
391379

392380
it('should go directly to amazon', () => {
393381
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');
382+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
383+
expect(s3.getFileLocation(testConfig, 'test.png')).toEqual('https://my-bucket.s3.amazonaws.com/foo/bar/test.png');
384+
});
385+
386+
it('should go directly to amazon using presigned URL', () => {
387+
delete options.baseUrl;
388+
options.presignedUrl = true;
389+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
390+
391+
const fileLocation = s3.getFileLocation(testConfig, 'test.png');
392+
expect(fileLocation).toMatch(/^https:\/\/my-bucket.s3.amazonaws.com\/foo\/bar\/test.png\?/);
393+
expect(fileLocation).toMatch(/X-Amz-Credential=accessKey%2F\d{8}%2Fus-east-1%2Fs3%2Faws4_request/);
394+
expect(fileLocation).toMatch(/X-Amz-Date=\d{8}T\d{6}Z/);
395+
expect(fileLocation).toMatch(/X-Amz-Signature=.{64}/);
396+
expect(fileLocation).toMatch(/X-Amz-Expires=\d{1,6}/);
397+
expect(fileLocation).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
398+
expect(fileLocation).toContain('X-Amz-SignedHeaders=host');
396399
});
397400
});
398401

@@ -406,7 +409,7 @@ describe('S3Adapter tests', () => {
406409
});
407410

408411
it('should be null by default', () => {
409-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
412+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
410413
expect(s3.validateFilename === null).toBe(true);
411414
});
412415

@@ -420,7 +423,7 @@ describe('S3Adapter tests', () => {
420423
}
421424
return null;
422425
};
423-
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
426+
const s3 = new S3Adapter('accessKey', 'secretKey', 'my-bucket', options);
424427
expect(s3.validateFilename('foo/bar') instanceof Parse.Error).toBe(true);
425428
});
426429
});

0 commit comments

Comments
 (0)