Skip to content
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

Add option to generate a presigned url with a expiration time #117

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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!
}
Expand All @@ -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,
Expand Down
52 changes: 38 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,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) {
Expand Down
4 changes: 4 additions & 0 deletions lib/optionsFromArguments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
153 changes: 79 additions & 74 deletions spec/test.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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(),
Expand All @@ -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) => {
Copy link
Member

@mtrezza mtrezza Jul 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please refactor this for simplicity. We normally do not use such a pattern anywhere where we wrap a whole list of tests into a forEach, so this may cause confusion or test execution issues, e.g. with test randomization.

Then this should be good to merge.

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

Expand All @@ -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);
});

Expand All @@ -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);
});
});
Expand Down