Skip to content

Commit

Permalink
feat: Add option to generate pre-signed URL with expiration time (#180)
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewalc authored May 12, 2023
1 parent 8311af9 commit d92363d
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 50 deletions.
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

0 comments on commit d92363d

Please sign in to comment.