Skip to content

feat: Add option to generate pre-signed URL with expiration time #180

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
May 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
642feac
Add option to generate a presigned url with a expiration time
danielsanfr Nov 17, 2020
6495047
Update README.md
danielsanfr Nov 17, 2020
ea46045
Uses AWS S3 presigned url together with baseUrl
danielsanfr Nov 19, 2020
1c4948c
Improves code to decrease complexity
danielsanfr Nov 19, 2020
1f82746
Simplify code complexity using "function early return" concept
danielsanfr Nov 20, 2020
e901d51
Update README.md
danielsanfr Nov 20, 2020
1051f98
Fix get presingedUrl and presignedUrl from class constructor args
danielsanfr Nov 20, 2020
9718934
Using the AWS S3 default expire time
danielsanfr Nov 20, 2020
140bdf9
When using s3client.getSignedUrl(), avoid return old path-style URL
danielsanfr Nov 20, 2020
7a3c5ca
Remove redundant code
danielsanfr Nov 20, 2020
21e4d9c
Add tests for presigned url
danielsanfr Nov 20, 2020
d761881
added new parameters to parameter table
mtrezza Nov 24, 2020
4b5dd8d
Small test improvement
danielsanfr Dec 4, 2020
2330ac6
Add test to ensure that the AWS S3 client receives getObject as the o…
danielsanfr Dec 4, 2020
0873698
Add comment to explain security concerns when using presigned URL
danielsanfr Feb 2, 2021
0a4b80c
refactored test according to previous PRs comment
andrewalc Mar 29, 2023
04bf89d
Merge branch 'master' into feature/presigned-url
mtrezza Apr 21, 2023
1e66f66
added back original tests
andrewalc Apr 25, 2023
e9797c3
Expires time is only set if defined. Defaults to AWS default value
andrewalc May 9, 2023
807e51c
updated readme with correct default val and s3 doc link
andrewalc May 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 35 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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 | `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`. |

### Using a config file

Expand All @@ -93,6 +95,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": 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.
"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
Expand Down Expand Up @@ -132,29 +136,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:
Expand Down Expand Up @@ -185,6 +195,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!
}
Expand All @@ -211,6 +223,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,
Expand Down
55 changes: 41 additions & 14 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -158,22 +175,32 @@ 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 };
if (this._presignedUrlExpires) {
params.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) {
Expand Down
4 changes: 4 additions & 0 deletions lib/optionsFromArguments.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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;
Expand Down Expand Up @@ -93,6 +95,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', null);
options = fromOptionsDictionaryOrDefault(options, 'generateKey', null);
options = fromOptionsDictionaryOrDefault(options, 'validateFilename', null);

Expand Down
115 changes: 100 additions & 15 deletions spec/test.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('S3Adapter tests', () => {
});
});


describe('should not throw when initialized properly', () => {
it('should accept a string bucket', () => {
expect(() => {
Expand Down Expand Up @@ -234,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) => {
Expand Down Expand Up @@ -265,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) => {
Expand All @@ -289,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(),
Expand Down Expand Up @@ -330,26 +331,26 @@ describe('S3Adapter tests', () => {
});

it('should get using the baseUrl', () => {
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
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 direct to baseUrl', () => {
options.baseUrlDirect = true;
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
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 without directAccess', () => {
options.directAccess = false;
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
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 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');
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');
});
});
describe('getFileLocation', () => {
Expand All @@ -373,26 +374,110 @@ describe('S3Adapter tests', () => {
});

it('should get using the baseUrl', () => {
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
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 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 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 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');
});
});
describe('getFileLocation', () => {
const testConfig = {
mount: 'http://my.server.com/parse',
applicationId: 'xxxx',
};
let options;

beforeEach(() => {
options = {
presignedUrl: false,
directAccess: true,
bucketPrefix: 'foo/bar/',
baseUrl: 'http://example.com/files',
};
});

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('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);
},
};

s3.getFileLocation(testConfig, 'test.png');
expect(getSignedUrlOperation).toBe('getObject');
});

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 direct to baseUrl', () => {
options.baseUrlDirect = true;
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
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 without directAccess', () => {
options.directAccess = false;
const s3 = new S3Adapter('accessKey', 'secretKey', 'myBucket', options);
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 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');
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 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');
});
});

Expand All @@ -406,7 +491,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);
});

Expand All @@ -420,7 +505,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);
});
});
Expand Down