From c60331abf85d400d66b9c0427f13f294991f2427 Mon Sep 17 00:00:00 2001 From: Stephen Sawchuk Date: Fri, 22 Aug 2014 17:03:08 -0400 Subject: [PATCH] storage: support signed urls. fixes #49. --- lib/storage/index.js | 59 +++++++++++++++++++++++++++++++++++++++++ regression/storage.js | 61 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/lib/storage/index.js b/lib/storage/index.js index e14574470ce..65ecd6fc2d6 100644 --- a/lib/storage/index.js +++ b/lib/storage/index.js @@ -20,6 +20,7 @@ 'use strict'; +var crypto = require('crypto'); var duplexify = require('duplexify'); var nodeutil = require('util'); var stream = require('stream'); @@ -230,6 +231,64 @@ Bucket.prototype.remove = function(name, callback) { this.makeReq('DELETE', path, null, true, callback); }; +/** + * Get a signed URL to allow limited time access to a resource. + * + * {@link https://developers.google.com/storage/docs/accesscontrol#Signed-URLs} + * + * @param {object} options - Configuration object. + * @param {string} options.action - "read", "write", or "delete" + * @param {string=} options.contentMd5 - The MD5 digest value in base64. If you + * provide this, the client must provide this HTTP header with this same + * value in its request. + * @param {string=} options.contentType - If you provide this value, the client + * must provide this HTTP header set to the same value. + * @param {number} options.expires - Timestamp (seconds since epoch) when this + * link will expire. + * @param {string=} options.extensionHeaders - If these headers are used, the + * server will check to make sure that the client provides matching values. + * @param {string} options.resource - Resource to allow access to. + * @return {string} + * + * @example + * ```js + * var signedUrl = bucket.getSignedUrl({ + * action: 'read', + * expires: Math.round(Date.now() / 1000) + (60 * 60 * 24 * 14), // 2 weeks. + * resource: 'my-dog.jpg' + * }); + * ``` + */ +Bucket.prototype.getSignedUrl = function(options) { + options.action = { + read: 'GET', + write: 'PUT', + delete: 'DELETE' + }[options.action]; + + options.resource = util.format('/{bucket}/{resource}', { + bucket: this.bucketName, + resource: options.resource + }); + + var sign = crypto.createSign('RSA-SHA256'); + sign.update([ + options.action, + (options.contentMd5 || ''), + (options.contentType || ''), + options.expires, + (options.extensionHeaders || '') + options.resource + ].join('\n')); + var signature = sign.sign(this.conn.credentials.private_key, 'base64'); + + return [ + 'http://storage.googleapis.com' + options.resource, + '?GoogleAccessId=' + this.conn.credentials.client_email, + '&Expires=' + options.expires, + '&Signature=' + encodeURIComponent(signature) + ].join(''); +}; + /** * Create a readable stream to read contents of the provided remote file. It * can be piped to a write stream, or listened to for 'data' events to read a diff --git a/regression/storage.js b/regression/storage.js index a5d9871aa13..c16a1f7930a 100644 --- a/regression/storage.js +++ b/regression/storage.js @@ -14,7 +14,7 @@ * limitations under the License. */ -/*global describe, it, before, after */ +/*global describe, it, before, after, beforeEach */ 'use strict'; @@ -22,6 +22,7 @@ var assert = require('assert'); var async = require('async'); var crypto = require('crypto'); var fs = require('fs'); +var request = require('request'); var tmp = require('tmp'); var env = require('./env.js'); @@ -189,4 +190,62 @@ describe('storage', function() { }), done); }); }); + + describe('sign urls', function() { + var fileName = 'LogoToSign.jpg'; + + beforeEach(function(done) { + fs.createReadStream(files.logo.path) + .pipe(bucket.createWriteStream(fileName)) + .on('error', done) + .on('complete', done.bind(null, null)); + }); + + it('should create a signed read url', function(done) { + var signedReadUrl = bucket.getSignedUrl({ + action: 'read', + expires: Math.round(Date.now() / 1000) + 5, + resource: fileName + }); + var localFile = fs.readFileSync(files.logo.path); + request.get(signedReadUrl, function(err, resp, body) { + assert.equal(body, localFile); + bucket.remove(fileName, done); + }); + }); + + it('should create a signed delete url', function(done) { + var signedDeleteUrl = bucket.getSignedUrl({ + action: 'delete', + expires: Math.round(Date.now() / 1000) + 5, + resource: fileName + }); + request.del(signedDeleteUrl, function(err, resp) { + assert.equal(resp.statusCode, 204); + bucket.stat(fileName, function(err) { + assert.equal(err.code, 404); + done(); + }); + }); + }); + + it('should allow control of expiration', function(done) { + var offsetSeconds = 5; + var signedReadUrl = bucket.getSignedUrl({ + action: 'read', + expires: Math.round(Date.now() / 1000) + offsetSeconds, + resource: fileName + }); + var localFile = fs.readFileSync(files.logo.path); + request.get(signedReadUrl, function(err, resp, body) { + assert.equal(body, localFile); + }); + setTimeout(function() { + request.get(signedReadUrl, function(err, resp) { + assert.equal(resp.statusCode, 400); + bucket.remove(fileName, done); + }); + }, (offsetSeconds + 1) * 1000); + }); + }); });