diff --git a/lib/storage/file.js b/lib/storage/file.js index c5d7ca3407c..a9d54237104 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -795,6 +795,148 @@ File.prototype.getSignedUrl = function(options, callback) { }); }; +/** + * Get a signed policy document to allow user to upload data + * with a POST. + * + * *[Reference](http://goo.gl/JWJEkG).* + * + * @throws {Error} if an expiration timestamp from the past is given or + * option parameter does not respect the expected format + * + * @param {object} options - Configuration object. + * @param {object} options.expiration - Timestamp (seconds since epoch) + * when this policy will expire. + * @param {object[][]=} options.equals - Array of request parameters and + * their expected value (e.g. [["$", ""]]). Values are + * translated into equality constraints in the conditions + * field of the policy document (e.g. ["eq", "$", ""]). + * If only one equality condition is to be specified, options.equals + * can be a one-dimensional array (e.g. ["$", ""]) + * @param {object[][]=} options.startsWith - Array of request parameters and + * their expected prefixes (e.g. [["$", ""]]). Values are + * translated into starts-with constraints in the conditions field + * of the policy document (e.g. ["starts-with", "$", ""]) + * If only one prefix condition is to be specified, options.startsWith + * can be a one-dimensional array (e.g. ["$", ""]) + * @param {string=} options.acl - ACL for the object from possibly predefined + * ACLs + * @param {string=} options.successRedirect - The URL to which the user + * client is redirected if the upload is successfull + * @param {string=} options.successStatus - The status of the Google Storage + * response if the upload is successfull (must be string) + * @param {object=} options.contentLengthRange - Object providing + * minimum (options.contentLengthRange.min) and maximum + * (options.contentLengthRange.max) value for the request's + * content length + * + * @example + * file.getSignedPolicy({ + * equals: ["$Content-Type", "image/jpeg"], + * contentLengthRange: {min: 0, max: 1024}, + * expiration: Math.round(Date.now() / 1000) + (60 * 60 * 24 * 14) // 2 weeks. + * }, function(err, policy) { + * // policy.string: the policy document, plain text + * // policy.base64: the policy document, base64 + * // policy.signature: the policy signature, base64 + * }); + */ +File.prototype.getSignedPolicy = function(options, callback) { + if (options.expiration < Math.floor(Date.now() / 1000)) { + throw new Error('An expiration date cannot be in the past.'); + } + + var expirationString = new Date(options.expiration).toISOString(); + var conditions = [ + ['eq', '$key', this.name], + { + bucket: this.bucket.name + }, + ]; + + if (util.is(options.equals, 'array')) { + if (!util.is(options.equals[0], 'array')) { + options.equals = [options.equals]; + } + options.equals.forEach(function(condition) { + if (!util.is(condition, 'array') || condition.length !== 2) { + throw new Error('Equals condition must be an array of 2 elements.'); + } + conditions.push(['eq', condition[0], condition[1]]); + }); + } + + if (util.is(options.startsWith, 'array')) { + if (!util.is(options.startsWith[0], 'array')) { + options.startsWith = [options.startsWith]; + } + options.startsWith.forEach(function(condition) { + if (!util.is(condition, 'array') || condition.length !== 2) { + throw new Error('StartsWith condition must be an array of 2 elements.'); + } + conditions.push(['starts-with', condition[0], condition[1]]); + }); + } + + if (options.acl) { + conditions.push({ + acl: options.acl + }); + } + + if (options.successRedirect) { + conditions.push({ + success_action_redirect: options.successRedirect + }); + } + + if (options.successStatus) { + conditions.push({ + success_action_status: options.successStatus + }); + } + + if (options.contentLengthRange) { + var min = options.contentLengthRange.min; + var max = options.contentLengthRange.max; + if (!util.is(min, 'number') || !util.is(max, 'number')) { + throw new Error( + 'ContentLengthRange must have numeric min and max fields.' + ); + } + conditions.push(['content-length-range', min, max]); + } + + var policy = { + expiration: expirationString, + conditions: conditions + }; + + var makeAuthorizedRequest_ = this.bucket.storage.makeAuthorizedRequest_; + + makeAuthorizedRequest_.getCredentials(function(err, credentials) { + if (err) { + callback(err); + return; + } + + var sign = crypto.createSign('RSA-SHA256'); + var policyString = JSON.stringify(policy); + var policyBase64 = new Buffer(policyString).toString('base64'); + + sign.update(policyBase64); + + var signature = sign.sign(credentials.private_key, 'base64'); + + callback(null, { + string: policyString, + base64: policyBase64, + signature: signature + }); + }); +}; + + /** * Set the file's metadata. * diff --git a/test/storage/file.js b/test/storage/file.js index 857478ab290..d9c129846b5 100644 --- a/test/storage/file.js +++ b/test/storage/file.js @@ -1041,6 +1041,206 @@ describe('File', function() { }); }); + describe('getSignedPolicy', function() { + var credentials = require('../testdata/privateKeyFile.json'); + + beforeEach(function() { + var storage = bucket.storage; + storage.makeAuthorizedRequest_.getCredentials = function(callback) { + callback(null, credentials); + }; + }); + + it('should create a signed policy', function(done) { + file.getSignedPolicy({ + expiration: Math.round(Date.now() / 1000) + 5 + }, function(err, signedPolicy) { + assert.ifError(err); + assert.equal(typeof signedPolicy.string, 'string'); + assert.equal(typeof signedPolicy.base64, 'string'); + assert.equal(typeof signedPolicy.signature, 'string'); + done(); + }); + }); + + it('should add key equality condition', function(done) { + file.getSignedPolicy({ + expiration: Math.round(Date.now() / 1000) + 5 + }, function(err, signedPolicy) { + var conditionString = '[\"eq\",\"$key\",\"'+file.name+'\"]'; + assert.ifError(err); + assert(signedPolicy.string.indexOf(conditionString) > -1); + done(); + }); + }); + + it('should add ACL condtion', function(done) { + file.getSignedPolicy({ + expiration: Math.round(Date.now() / 1000) + 5, + acl: '' + }, function(err, signedPolicy) { + var conditionString = '{\"acl\":\"\"}'; + assert.ifError(err); + assert(signedPolicy.string.indexOf(conditionString) > -1); + done(); + }); + }); + + describe('expiration', function() { + it('should ISO encode expiration', function(done) { + var expiration = Math.round(Date.now() / 1000) + 5; + var expireDate = new Date(expiration); + file.getSignedPolicy({ + expiration: expiration + }, function(err, signedPolicy) { + assert.ifError(err); + assert(signedPolicy.string.indexOf(expireDate.toISOString()) > -1); + done(); + }); + }); + + it('should throw if a date from the past is given', function() { + var expirationTimestamp = Math.floor(Date.now() / 1000) - 1; + assert.throws(function() { + file.getSignedPolicy({ + expiration: expirationTimestamp + }, function() {}); + }, /cannot be in the past/); + }); + }); + + describe('equality condition', function() { + it('should add equality conditions (array of arrays)', function(done) { + var expiration = Math.round(Date.now() / 1000) + 5; + file.getSignedPolicy({ + expiration: expiration, + equals: [['$', '']] + }, function(err, signedPolicy) { + var conditionString = '[\"eq\",\"$\",\"\"]'; + assert.ifError(err); + assert(signedPolicy.string.indexOf(conditionString) > -1); + done(); + }); + }); + + it('should add equality condition (array)', function(done) { + var expiration = Math.round(Date.now() / 1000) + 5; + file.getSignedPolicy({ + expiration: expiration, + equals: ['$', ''] + }, function(err, signedPolicy) { + var conditionString = '[\"eq\",\"$\",\"\"]'; + assert.ifError(err); + assert(signedPolicy.string.indexOf(conditionString) > -1); + done(); + }); + }); + + it('should throw if equal condition is not an array', function() { + var expiration = Math.round(Date.now() / 1000) + 5; + assert.throws(function() { + file.getSignedPolicy({ + expiration: expiration, + equals: [{}] + }, function() {}); + }, /Equals condition must be an array/); + }); + + it('should throw if equal condition length is not 2', function() { + var expiration = Math.round(Date.now() / 1000) + 5; + assert.throws(function() { + file.getSignedPolicy({ + expiration: expiration, + equals: [['1', '2', '3']] + }, function() {}); + }, /Equals condition must be an array of 2 elements/); + }); + }); + + describe('prefix conditions', function() { + it('should add prefix conditions (array of arrays)', function(done) { + var expiration = Math.round(Date.now() / 1000) + 5; + file.getSignedPolicy({ + expiration: expiration, + startsWith: [['$', '']] + }, function(err, signedPolicy) { + var conditionString = '[\"starts-with\",\"$\",\"\"]'; + assert.ifError(err); + assert(signedPolicy.string.indexOf(conditionString) > -1); + done(); + }); + }); + + it('should add prefix condition (array)', function(done) { + var expiration = Math.round(Date.now() / 1000) + 5; + file.getSignedPolicy({ + expiration: expiration, + startsWith: ['$', ''] + }, function(err, signedPolicy) { + var conditionString = '[\"starts-with\",\"$\",\"\"]'; + assert.ifError(err); + assert(signedPolicy.string.indexOf(conditionString) > -1); + done(); + }); + }); + + it('should throw if prexif condition is not an array', function() { + var expiration = Math.round(Date.now() / 1000) + 5; + assert.throws(function() { + file.getSignedPolicy({ + expiration: expiration, + startsWith: [{}] + }, function() {}); + }, /StartsWith condition must be an array/); + }); + + it('should throw if prefix condition length is not 2', function() { + var expiration = Math.round(Date.now() / 1000) + 5; + assert.throws(function() { + file.getSignedPolicy({ + expiration: expiration, + startsWith: [['1', '2', '3']] + }, function() {}); + }, /StartsWith condition must be an array of 2 elements/); + }); + }); + + describe('content length', function() { + it('should add content length condition', function(done) { + var expiration = Math.round(Date.now() / 1000) + 5; + file.getSignedPolicy({ + expiration: expiration, + contentLengthRange: {min: 0, max: 1} + }, function(err, signedPolicy) { + var conditionString = '[\"content-length-range\",0,1]'; + assert.ifError(err); + assert(signedPolicy.string.indexOf(conditionString) > -1); + done(); + }); + }); + + it('should throw if content length has no min', function() { + var expiration = Math.round(Date.now() / 1000) + 5; + assert.throws(function() { + file.getSignedPolicy({ + expiration: expiration, + contentLengthRange: [{max: 1}] + }, function() {}); + }, /ContentLengthRange must have numeric min and max fields/); + }); + + it('should throw if content length has no max', function() { + var expiration = Math.round(Date.now() / 1000) + 5; + assert.throws(function() { + file.getSignedPolicy({ + expiration: expiration, + contentLengthRange: [{min: 0}] + }, function() {}); + }, /ContentLengthRange must have numeric min and max fields/); + }); + }); + }); + describe('setMetadata', function() { var metadata = { fake: 'metadata' };