diff --git a/lib/storage/bucket.js b/lib/storage/bucket.js index 3689ea70a5f..d815f2707c9 100644 --- a/lib/storage/bucket.js +++ b/lib/storage/bucket.js @@ -24,6 +24,7 @@ var extend = require('extend'); var fs = require('fs'); var mime = require('mime-types'); var path = require('path'); +var async = require('async'); /** * @type {module:storage/acl} @@ -286,6 +287,7 @@ Bucket.prototype.getFiles = function(query, callback) { return file; }); var nextQuery = null; + if (resp.nextPageToken) { nextQuery = extend({}, query, { pageToken: resp.nextPageToken }); } @@ -348,6 +350,142 @@ Bucket.prototype.setMetadata = function(metadata, callback) { }); }; +/** + * Make the bucket listing and optionally its contents publicly readable to + * anybody. + * NOTE: This function may be long-running and use a high number of requests as + * it recursively applies makePublic to every file within the bucket. + * + * @param {object=} options - The configuration object. + * @param {boolean} options.recursive - Whether to recursively apply makePublic + * to every file in the bucket as well. Default is false. + * @param {boolean} options.force - By default, makePublic will return at the + * first error found, but set this to true to continue processing if this + * occurs. + * @param {Function} callback - The callback function. + * + * @example + * bucket.makePublic({ recursive: true }, function(err, files) { + * // err is the first error to occur, otherwise null. + * // files is an array of files successfully made public in the bucket. + * }); + */ +Bucket.prototype.makePublic = function(options, callback) { + if (util.is(options, 'function')) { + callback = options; + options = {}; + } + + options.public = true; + this._makePublicPrivate(options, callback); +}; + +Bucket.prototype.makePrivate = function(options, callback) { + if (util.is(options, 'function')) { + callback = options; + options = {}; + } + + options.private = true; + this._makePublicPrivate(options, callback); +}; + +Bucket.prototype._makePublicPrivate = function(options, callback) { + var self = this; + var errors = []; + var filesProcessed = []; + var nextQuery = {}; + + // Allow public to read bucket contents + // while preserving original permissions + if (options.public) { + this.acl.add({ + entity: 'allUsers', + role: 'READER' + }, processAllFiles); + } else if (options.private) { + var query = { + predefinedAcl: options.strict ? 'private' : 'projectPrivate' + }; + + // You aren't allowed to set both predefinedAcl & acl properties on a bucket + // so acl must explicitly be nullified. + var metadata = { acl: null }; + + this.makeReq_('PATCH', '', query, metadata, function(err, resp) { + if (err) { + processAllFiles(err); + return; + } + self.metadata = resp; + processAllFiles(null); + }); + } + + function processAllFiles(err) { + if (err) { + callback(err); + return; + } + + if (options.recursive) { + async.whilst(function() { + // while there are still more files to make public + return !!nextQuery; + }, function(cb) { + processMoreFiles(nextQuery, cb); + }, function(err) { + if (err || errors.length) { + callback(err || errors, filesProcessed); + } else { + callback(null, filesProcessed); + } + }); + } else { + callback(null, filesProcessed); + } + } + + function processMoreFiles(query, callback) { + self.getFiles(query, function(err, files, nextPageQuery) { + if (err) { + callback(err); + return; + } + + // Iterate through each file and make it public. + async.eachLimit(files, 10, function(file, cb) { + if (options.public) { + file.makePublic(processedCallback); + } else if (options.private) { + file.makePrivate({ strict: options.strict }, processedCallback); + } + + function processedCallback(err) { + if (err) { + if (!options.force) { + cb(err); + } else { + errors.push(err); + cb(); + } + } else { + filesProcessed.push(file); + cb(); + } + } + }, function(err) { + if (err) { + callback(err); + } else { + nextQuery = nextPageQuery; + callback(); + } + }); + }); + } +}; + /** * Upload a file to the bucket. This is a convenience method that wraps the * functionality provided by a File object, {module:storage/file}. diff --git a/lib/storage/file.js b/lib/storage/file.js index c073fe23868..c57688112a4 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -797,6 +797,73 @@ File.prototype.setMetadata = function(metadata, callback) { }); }; +/** + * Make a file private to the project and remove all other permissions. + * Set `options.strict` to true to make the file private to only the owner. + * + * @param {object=} options - The configuration object. + * @param {boolean=} options.strict - If true, set the file to be private to + * only the owner user. Otherwise, it will be private to the project. + * @param {function=} callback - The callback function. + * + * @example + * + * //- + * // Set the file private so only project maintainers can see and modify it. + * //- + * file.makePrivate(function(err) {}); + * + * //- + * // Set the file private so only the owner can see and modify it. + * //- + * file.makePrivate({ strict: true }, function(err) {}); + */ +File.prototype.makePrivate = function(options, callback) { + var that = this; + if (util.is(options, 'function')) { + callback = options; + options = {}; + } + var path = '/o/' + encodeURIComponent(this.name); + var query = { predefinedAcl: options.strict ? 'private' : 'projectPrivate' }; + + // You aren't allowed to set both predefinedAcl & acl properties on a file, so + // acl must explicitly be nullified, destroying all previous acls on the file. + var metadata = { acl: null }; + + callback = callback || util.noop; + + this.makeReq_('PATCH', path, query, metadata, function(err, resp) { + if (err) { + callback(err); + return; + } + + that.metadata = resp; + + callback(null); + }); +}; + +/** + * Set a file to be publicly readable and maintain all previous permissions. + * + * @param {function=} callback - The callback function. + * + * @example + * file.makePublic(function(err) {}); + */ +File.prototype.makePublic = function(callback) { + callback = callback || util.noop; + + this.acl.add({ + entity: 'allUsers', + role: 'READER' + }, function(err) { + callback(err); + }); +}; + /** * `startResumableUpload_` uses the Resumable Upload API: http://goo.gl/jb0e9D. * diff --git a/regression/storage.js b/regression/storage.js index dc8f7efc4d7..86af43b657e 100644 --- a/regression/storage.js +++ b/regression/storage.js @@ -236,6 +236,32 @@ describe('storage', function() { }); }); }); + + it('should be made public', function(done) { + file.makePublic(function(err) { + assert.ifError(err); + file.acl.get({ entity: 'allUsers' }, function(err, aclObject) { + assert.ifError(err); + assert.deepEqual(aclObject, { entity: 'allUsers', role: 'READER' }); + file.acl.delete({ entity: 'allUsers' }, done); + }); + }); + }); + + it('should be made private', function(done) { + file.makePublic(function(err) { + assert.ifError(err); + file.makePrivate(function(err) { + assert.ifError(err); + file.acl.get({ entity: 'allUsers' }, function(err, aclObject) { + assert.equal(err.code, 404); + assert.equal(err.message, 'Not Found'); + assert.equal(aclObject, null); + done(); + }); + }); + }); + }); }); }); diff --git a/test/storage/file.js b/test/storage/file.js index ba29f478807..d4f2c5da5bb 100644 --- a/test/storage/file.js +++ b/test/storage/file.js @@ -1025,6 +1025,59 @@ describe('File', function() { }); }); + describe('makePublic', function() { + it('should execute callback', function(done) { + file.acl.add = function(options, callback) { + callback(); + }; + + file.makePublic(done); + }); + + it('should make the file public', function(done) { + file.acl.add = function(options) { + assert.deepEqual(options, { entity: 'allUsers', role: 'READER' }); + done(); + }; + + file.makePublic(util.noop); + }); + }); + + describe('makePrivate', function() { + it('should execute callback', function(done) { + file.makeReq_ = function(method, path, query, body, callback) { + callback(); + }; + + file.makePrivate(done); + }); + + it('should make the file private to project by default', function(done) { + file.makeReq_ = function(method, path, query, body) { + assert.equal(method, 'PATCH'); + assert.equal(path, '/o/' + encodeURIComponent(file.name)); + assert.deepEqual(query, { predefinedAcl: 'projectPrivate' }); + assert.deepEqual(body, { acl: null }); + done(); + }; + + file.makePrivate(util.noop); + }); + + it('should make the file private to user if strict = true', function(done) { + file.makeReq_ = function(method, path, query, body) { + assert.equal(method, 'PATCH'); + assert.equal(path, '/o/' + encodeURIComponent(file.name)); + assert.deepEqual(query, { predefinedAcl: 'private' }); + assert.deepEqual(body, { acl: null }); + done(); + }; + + file.makePrivate({ strict: true }, util.noop); + }); + }); + describe('startResumableUpload_', function() { var RESUMABLE_URI = 'http://resume';