From f89fc9e979104eb6c8f252c17da5fbd06f7d6109 Mon Sep 17 00:00:00 2001 From: Geoff Jacobsen Date: Wed, 15 May 2013 16:42:16 +1200 Subject: [PATCH 001/335] Return counts for collection update and remove commands In order to support optimistic locking the counts from the MongoDB update and remove commands need to be returned to the application code so that the update/remove can be verified they took place and action taken if they did not (like throwing a 409 Error). For consistency make LocalCollection return counts also. --- packages/minimongo/minimongo.js | 5 +++++ packages/minimongo/minimongo_tests.js | 20 +++++++++++++------ packages/mongo-livedata/collection.js | 4 +++- packages/mongo-livedata/mongo_driver.js | 6 ++++-- .../mongo-livedata/mongo_livedata_tests.js | 17 ++++++++++++++-- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index ceac91e251..256e450005 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -498,6 +498,7 @@ LocalCollection.prototype.remove = function (selector) { LocalCollection._recomputeResults(query); }); self._observeQueue.drain(); + return remove.length; }; // XXX atomicity: if multi is true, and one modification fails, do @@ -523,12 +524,15 @@ LocalCollection.prototype.update = function (selector, mod, options) { }); var recomputeQids = {}; + var updateCount = 0; + for (var id in self.docs) { var doc = self.docs[id]; if (selector_f(doc)) { // XXX Should we save the original even if mod ends up being a no-op? self._saveOriginal(id, doc); self._modifyAndNotify(doc, mod, recomputeQids); + ++updateCount; if (!options.multi) break; } @@ -541,6 +545,7 @@ LocalCollection.prototype.update = function (selector, mod, options) { qidToOriginalResults[qid]); }); self._observeQueue.drain(); + return updateCount; }; LocalCollection.prototype._modifyAndNotify = function ( diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 23b938dea6..e30784e46a 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -66,7 +66,8 @@ var log_callbacks = function (operations) { // XXX test shared structure in all MM entrypoints Tinytest.add("minimongo - basics", function (test) { var c = new LocalCollection(), - fluffyKitten_id; + fluffyKitten_id, + count; fluffyKitten_id = c.insert({type: "kitten", name: "fluffy"}); c.insert({type: "kitten", name: "snookums"}); @@ -87,7 +88,8 @@ Tinytest.add("minimongo - basics", function (test) { test.length(c.find({type: "kitten"}).fetch(), 2); test.length(c.find({type: "cryptographer"}).fetch(), 2); - c.update({name: "snookums"}, {$set: {type: "cryptographer"}}); + count = c.update({name: "snookums"}, {$set: {type: "cryptographer"}}); + test.equal(count, 1); test.equal(c.find().count(), 4); test.equal(c.find({type: "kitten"}).count(), 1); test.equal(c.find({type: "cryptographer"}).count(), 3); @@ -102,10 +104,12 @@ Tinytest.add("minimongo - basics", function (test) { c.remove({_id: null}); c.remove({_id: false}); c.remove({_id: undefined}); - c.remove(); + count = c.remove(); + test.equal(count, 0); test.equal(c.find().count(), 4); - c.remove({}); + count = c.remove({}); + test.equal(count, 4); test.equal(c.find().count(), 0); c.insert({_id: 1, name: "strawberry", tags: ["fruit", "red", "squishy"]}); @@ -1647,7 +1651,8 @@ Tinytest.add("minimongo - diff", function (test) { Tinytest.add("minimongo - saveOriginals", function (test) { // set up some data - var c = new LocalCollection(); + var c = new LocalCollection(), + count; c.insert({_id: 'foo', x: 'untouched'}); c.insert({_id: 'bar', x: 'updateme'}); c.insert({_id: 'baz', x: 'updateme'}); @@ -1658,9 +1663,12 @@ Tinytest.add("minimongo - saveOriginals", function (test) { c.saveOriginals(); c.insert({_id: "hooray", z: 'insertme'}); c.remove({y: 'removeme'}); - c.update({x: 'updateme'}, {$set: {z: 5}}, {multi: true}); + count = c.update({x: 'updateme'}, {$set: {z: 5}}, {multi: true}); c.update('bar', {$set: {k: 7}}); // update same doc twice + // Verify returned count is correct + test.equal(count, 2); + // Verify the originals. var originals = c.retrieveOriginals(); var affected = ['bar', 'baz', 'quux', 'whoa', 'hooray']; diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index d67ea71158..b0bfd0f899 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -357,7 +357,9 @@ _.each(["insert", "update", "remove"], function (name) { // it's my collection. descend into the collection object // and propagate any exception. try { - self._collection[name].apply(self._collection, args); + var result = self._collection[name].apply(self._collection, args); + if (ret === undefined) + ret = result; } catch (e) { if (callback) { callback(e); diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 1922570c4e..f1aa92a59b 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -228,9 +228,10 @@ _Mongo.prototype.remove = function (collection_name, selector) { var future = new Future; collection.remove(replaceTypes(selector, replaceMeteorAtomWithMongo), {safe: true}, future.resolver()); - future.wait(); + var result = future.wait(); // XXX We don't have to run this on error, right? self._refresh(collection_name, selector); + return result; } finally { write.committed(); } @@ -266,8 +267,9 @@ _Mongo.prototype.update = function (collection_name, selector, mod, options) { collection.update(replaceTypes(selector, replaceMeteorAtomWithMongo), replaceTypes(mod, replaceMeteorAtomWithMongo), mongoOpts, future.resolver()); - future.wait(); + var result = future.wait(); self._refresh(collection_name, selector); + return result; } finally { write.committed(); } diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 948a7962bd..f7875fff5e 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -191,8 +191,14 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on test.equal(_.pluck(coll.find({run: run}, {sort: {x: -1}}).fetch(), "x"), [4, 1]); + expectObserve('', function () { + var count = coll.update({run: run, x: -1}, {$inc: {x: 2}}, {multi: true}); + test.equal(count, 0); + }); + expectObserve('c(3,0,1)c(6,1,4)', function () { - coll.update({run: run}, {$inc: {x: 2}}, {multi: true}); + var count = coll.update({run: run}, {$inc: {x: 2}}, {multi: true}); + test.equal(count, 2); test.equal(_.pluck(coll.find({run: run}, {sort: {x: -1}}).fetch(), "x"), [6, 3]); }); @@ -205,7 +211,8 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on }); expectObserve('r(13,1)', function () { - coll.remove({run: run, x: {$gt: 10}}); + var count = coll.remove({run: run, x: {$gt: 10}}); + test.equal(count, 1); test.equal(coll.find({run: run}).count(), 1); }); @@ -214,6 +221,12 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on test.equal(coll.find({run: run}).count(), 0); }); + expectObserve('', function () { + var count = coll.remove({run: run}); + test.equal(count, 0); + test.equal(coll.find({run: run}).count(), 0); + }); + obs.stop(); onComplete(); }); From c4203e35000e308c95ded657cbf5fd64fc1b3ff3 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Mon, 26 Aug 2013 19:28:25 -0700 Subject: [PATCH 002/335] Fix typo in package description. --- packages/amplify/package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amplify/package.js b/packages/amplify/package.js index c808eda438..012777f8b1 100644 --- a/packages/amplify/package.js +++ b/packages/amplify/package.js @@ -1,5 +1,5 @@ Package.describe({ - summary: "API for Persistant Storage, PubSub and Request" + summary: "API for Persistent Storage, PubSub and Request" }); Package.on_use(function (api) { From 6b9bfe8b5ace6418b557775d274ce88485b670af Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Thu, 15 Aug 2013 12:20:29 -0700 Subject: [PATCH 003/335] update esprima to take fix for regex parsing bug --- packages/js-analyze/.npm/package/npm-shrinkwrap.json | 2 +- packages/js-analyze/package.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/js-analyze/.npm/package/npm-shrinkwrap.json b/packages/js-analyze/.npm/package/npm-shrinkwrap.json index 04b9946958..52f2263019 100644 --- a/packages/js-analyze/.npm/package/npm-shrinkwrap.json +++ b/packages/js-analyze/.npm/package/npm-shrinkwrap.json @@ -1,7 +1,7 @@ { "dependencies": { "esprima": { - "from": "https://github.com/ariya/esprima/tarball/5044b87f94fb802d9609f1426c838874ec2007b3" + "from": "https://github.com/ariya/esprima/tarball/2a41dbf0ddadade0b09a9a7cc9a0c8df9c434018" }, "estraverse": { "version": "1.1.2-1" diff --git a/packages/js-analyze/package.js b/packages/js-analyze/package.js index 71087fefc3..8bc4b460b6 100644 --- a/packages/js-analyze/package.js +++ b/packages/js-analyze/package.js @@ -13,7 +13,7 @@ Package.describe({ Npm.depends({ // This code was originally written against the unreleased 1.1 branch. We can // probably switch to a built NPM version when it gets released. - esprima: "https://github.com/ariya/esprima/tarball/5044b87f94fb802d9609f1426c838874ec2007b3", + esprima: "https://github.com/ariya/esprima/tarball/2a41dbf0ddadade0b09a9a7cc9a0c8df9c434018", estraverse: "1.1.2-1", // Fork to add ignoreEval option. // https://github.com/Constellation/escope/pull/18 From 5784c811aee9e8da0133113073879fe37eb5c79d Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 20 Aug 2013 11:14:44 -0700 Subject: [PATCH 004/335] Don't number lines that end in backslash. Fixes #1326 --- tools/linker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/linker.js b/tools/linker.js index 8392d28877..6d12925d51 100644 --- a/tools/linker.js +++ b/tools/linker.js @@ -355,7 +355,7 @@ _.extend(File.prototype, { _.each(lines, function (line) { var suffix = "\n"; - if (line.length <= width) { + if (line.length <= width && line[line.length - 1] !== "\\") { suffix = padding.slice(line.length, width) + " // " + num + "\n"; } f(line, suffix, num); From a79db877a4badec4777a74efdb729fb94fdac84a Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Mon, 19 Aug 2013 16:25:46 -0700 Subject: [PATCH 005/335] Add EJSON to standard-app-packages. Fixes #1343. --- packages/standard-app-packages/package.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/standard-app-packages/package.js b/packages/standard-app-packages/package.js index eeb12c1574..147674e8e5 100644 --- a/packages/standard-app-packages/package.js +++ b/packages/standard-app-packages/package.js @@ -38,6 +38,8 @@ Package.on_use(function(api) { // $(".usefulToo") 'jquery', // Life isn't always predictable. - 'random' + 'random', + // People like being able to clone objects. + 'ejson' ]); }); From 70a691661d59bd6b781fc0f6a4d86f573d46d5d3 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 20 Aug 2013 11:37:11 -0700 Subject: [PATCH 006/335] Use decoded pathnames in staticFiles keys. Fixes #1339 --- packages/webapp/webapp_server.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index d167942557..a3fc604ab1 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -234,10 +234,14 @@ var runWebAppServer = function () { // Auto-compress any json, javascript, or text. app.use(connect.compress()); + var getItemPathname = function (itemUrl) { + return decodeURIComponent(url.parse(itemUrl).pathname); + }; + var staticFiles = {}; _.each(clientJson.manifest, function (item) { if (item.url && item.where === "client") { - staticFiles[url.parse(item.url).pathname] = { + staticFiles[getItemPathname(item.url)] = { path: item.path, cacheable: item.cacheable, // Link from source to its map @@ -247,7 +251,7 @@ var runWebAppServer = function () { if (item.sourceMap) { // Serve the source map too, under the specified URL. We assume all // source maps are cacheable. - staticFiles[url.parse(item.sourceMapUrl).pathname] = { + staticFiles[getItemPathname(item.sourceMapUrl)] = { path: item.sourceMap, cacheable: true }; From 9847720a3b79c2d25573347d982c34c13779f26d Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 19 Aug 2013 21:47:34 -0700 Subject: [PATCH 007/335] Various bugs involving idStringify. In minimongo, we support various types as _id fields; the most important are strings and ObjectIds. We have a special encoding that we use when we need to represent them as strings, but we had a few bugs with using it. minimongo: The duplicate-ID check in insert needed to check using the encoded string. spark OrderedDict: due to linker changes, it was not successfully finding LocalCollection._idStringify, and so it fell back to the identity function. Fixed to use weak dependencies. Also, later refactorings have removed the need for idStringify(null) === null so that special case is removed. spark branch labels: use idStringify in various places where _id is used as a branch label. --- packages/handlebars/evaluate-handlebars.js | 7 ++++++- packages/handlebars/package.js | 4 ++++ packages/minimongo/minimongo.js | 2 +- packages/spark/convenience.js | 3 ++- packages/spark/package.js | 3 +++ packages/spark/spark.js | 19 +++++++------------ packages/templating/deftemplate.js | 6 +++++- packages/templating/package.js | 3 +++ 8 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/handlebars/evaluate-handlebars.js b/packages/handlebars/evaluate-handlebars.js index fa3df9bcd2..05b7d9deba 100644 --- a/packages/handlebars/evaluate-handlebars.js +++ b/packages/handlebars/evaluate-handlebars.js @@ -12,6 +12,10 @@ Handlebars.json_ast_to_func = function (ast) { }; }; +var idStringify = Package.minimongo + ? Package.minimongo.LocalCollection._idStringify + : function (id) { return id; }; + // block helpers take: // (N args), options (hash args, plus 'fn' and 'inverse') // and return text @@ -38,7 +42,8 @@ Handlebars._default_helpers = { if (data && data.length > 0) return _.map(data, function(x, i) { // infer a branch key from the data - var branch = ((x && x._id) || (typeof x === 'string' ? x : null) || + var branch = ((x && x._id && idStringify(x._id)) || + (typeof x === 'string' ? x : null) || Spark.UNIQUE_LABEL); return Spark.labelBranch(branch, function() { return options.fn(x); diff --git a/packages/handlebars/package.js b/packages/handlebars/package.js index 90e3631cce..dfbecc219f 100644 --- a/packages/handlebars/package.js +++ b/packages/handlebars/package.js @@ -11,6 +11,10 @@ Package.on_use(function (api) { api.export('Handlebars'); + + // If we have minimongo available, use its idStringify function. + api.use('minimongo', 'client', {weak: true}); + // XXX these should be split up into two different slices, not // different code with totally different APIs that is sent depending // on the architecture diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index d50b9de011..7787977f10 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -427,7 +427,7 @@ LocalCollection.prototype.insert = function (doc, callback) { } var id = LocalCollection._idStringify(doc._id); - if (_.has(self.docs, doc._id)) + if (_.has(self.docs, id)) throw MinimongoError("Duplicate _id '" + doc._id + "'"); self._saveOriginal(id, undefined); diff --git a/packages/spark/convenience.js b/packages/spark/convenience.js index e4b3c3f81a..a8e5a82fb6 100644 --- a/packages/spark/convenience.js +++ b/packages/spark/convenience.js @@ -11,7 +11,8 @@ Meteor.render = function (htmlFunc) { Meteor.renderList = function (cursor, itemFunc, elseFunc) { return Spark.render(function () { return Spark.list(cursor, function (item) { - return Spark.labelBranch(item._id || null, function () { + var label = item._id ? idStringify(item._id) : null; + return Spark.labelBranch(label, function () { return Spark.isolate(_.bind(itemFunc, null, item)); }); }, function () { diff --git a/packages/spark/package.js b/packages/spark/package.js index afcbc42d58..8b2f8fbeb5 100644 --- a/packages/spark/package.js +++ b/packages/spark/package.js @@ -8,6 +8,9 @@ Package.on_use(function (api) { 'ordered-dict', 'deps', 'ejson'], 'client'); + // If we have minimongo available, use its idStringify function. + api.use('minimongo', 'client', {weak: true}); + api.export('Spark', 'client'); api.export('SparkTest', 'client', {testOnly: true}); diff --git a/packages/spark/spark.js b/packages/spark/spark.js index 7c3cf27911..2055cc7a67 100644 --- a/packages/spark/spark.js +++ b/packages/spark/spark.js @@ -903,18 +903,13 @@ var applyChanges = function (doc, changeFields) { }; -var idStringify; - -if (typeof LocalCollection !== 'undefined') { - idStringify = function (id) { - if (id === null) - return id; - else - return LocalCollection._idStringify(id); - }; -} else { - idStringify = function (id) { return id; }; -} +// If minimongo is available (it's a weak dependency) use its ID stringifier (so +// that, eg, ObjectId and strings don't overlap). Otherwise just use the +// identity function. +// This is also used in convenience.js. +idStringify = Package.minimongo + ? Package.minimongo.LocalCollection._idStringify + : function (id) { return id; }; Spark.list = function (cursor, itemFunc, elseFunc) { elseFunc = elseFunc || function () { return ''; }; diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js index ad27795451..ba5fb14ca7 100644 --- a/packages/templating/deftemplate.js +++ b/packages/templating/deftemplate.js @@ -2,6 +2,10 @@ Template = {}; var registeredPartials = {}; +var idStringify = Package.minimongo + ? Package.minimongo.LocalCollection._idStringify + : function (id) { return id; }; + // XXX Handlebars hooking is janky and gross var hookHandlebars = function () { hookHandlebars = function(){}; // install the hook only once @@ -17,7 +21,7 @@ var hookHandlebars = function () { arg, function (item) { return Spark.labelBranch( - (item && item._id) || Spark.UNIQUE_LABEL, function () { + (item && item._id && idStringify(item._id)) || Spark.UNIQUE_LABEL, function () { var html = Spark.isolate(_.bind(options.fn, null, item)); return Spark.setDataContext(item, html); }); diff --git a/packages/templating/package.js b/packages/templating/package.js index 702ef253d2..1dc56a6de7 100644 --- a/packages/templating/package.js +++ b/packages/templating/package.js @@ -26,6 +26,9 @@ Package.on_use(function (api) { api.export('Template', 'client'); + + // If we have minimongo available, use its idStringify function. + api.use('minimongo', 'client', {weak: true}); // provides the runtime logic to instantiate our templates api.add_files('deftemplate.js', 'client'); From 99d29cc9d2386cb63e13ba25720c2b9870710197 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 19 Aug 2013 22:15:52 -0700 Subject: [PATCH 008/335] More comments on idStringify fix. --- packages/handlebars/evaluate-handlebars.js | 3 +++ packages/templating/deftemplate.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/handlebars/evaluate-handlebars.js b/packages/handlebars/evaluate-handlebars.js index 05b7d9deba..ab895c2550 100644 --- a/packages/handlebars/evaluate-handlebars.js +++ b/packages/handlebars/evaluate-handlebars.js @@ -12,6 +12,9 @@ Handlebars.json_ast_to_func = function (ast) { }; }; +// If minimongo is available (it's a weak dependency) use its ID stringifier to +// label branches (so that, eg, ObjectId and strings don't overlap). Otherwise +// just use the identity function. var idStringify = Package.minimongo ? Package.minimongo.LocalCollection._idStringify : function (id) { return id; }; diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js index ba5fb14ca7..7aafefcb1b 100644 --- a/packages/templating/deftemplate.js +++ b/packages/templating/deftemplate.js @@ -2,6 +2,9 @@ Template = {}; var registeredPartials = {}; +// If minimongo is available (it's a weak dependency) use its ID stringifier to +// label branches (so that, eg, ObjectId and strings don't overlap). Otherwise +// just use the identity function. var idStringify = Package.minimongo ? Package.minimongo.LocalCollection._idStringify : function (id) { return id; }; From 2c68882fffc47e92aa237ec96f9620cf469e5b16 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 20 Aug 2013 11:08:50 -0700 Subject: [PATCH 009/335] Fix d3 tab character --- packages/d3/d3.v3.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/d3/d3.v3.js b/packages/d3/d3.v3.js index 08aff88cea..361a3e113e 100644 --- a/packages/d3/d3.v3.js +++ b/packages/d3/d3.v3.js @@ -1789,7 +1789,7 @@ d3 = function() { return dsv; } d3.csv = d3_dsv(",", "text/csv"); - d3.tsv = d3_dsv(" ", "text/tab-separated-values"); + d3.tsv = d3_dsv("\t", "text/tab-separated-values"); var d3_timer_id = 0, d3_timer_byId = {}, d3_timer_queue = null, d3_timer_interval, d3_timer_timeout; d3.timer = function(callback, delay, then) { if (arguments.length < 3) { From 61d9510ef173e902c4b1ac4d2233efc7da95c753 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Mon, 19 Aug 2013 19:51:01 -0700 Subject: [PATCH 010/335] Export Spiderable symbol, so people can override user agents. Also add twitterbot to agents. Fixes #1300. --- packages/spiderable/package.js | 2 ++ packages/spiderable/spiderable.js | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/spiderable/package.js b/packages/spiderable/package.js index 425c2ae8bb..431cc5c77e 100644 --- a/packages/spiderable/package.js +++ b/packages/spiderable/package.js @@ -7,6 +7,8 @@ Package.on_use(function (api) { api.use(['templating'], 'client'); api.use(['underscore'], ['client', 'server']); + api.export('Spiderable', 'server'); + api.add_files('spiderable.html', 'client'); api.add_files('spiderable.js', 'server'); }); diff --git a/packages/spiderable/spiderable.js b/packages/spiderable/spiderable.js index 2e5bd04c4a..9bc8f8118f 100644 --- a/packages/spiderable/spiderable.js +++ b/packages/spiderable/spiderable.js @@ -9,7 +9,13 @@ Spiderable = {}; // not obey the _escaped_fragment_ protocol. The page is served // statically to any client whos user agent matches any of these // regexps. Users may modify this array. -Spiderable.userAgentRegExps = [/^facebookexternalhit/i, /^linkedinbot/i]; +// +// An original goal with the spiderable package was to avoid doing +// user-agent based tests. But the reality is not enough bots support +// the _escaped_fragment_ protocol, so we need to hardcode a list +// here. I shed a silent tear. +Spiderable.userAgentRegExps = [ + /^facebookexternalhit/i, /^linkedinbot/i, /^twitterbot/i]; // how long to let phantomjs run before we kill it var REQUEST_TIMEOUT = 15*1000; From 7f73833991c6e3c37a72787200beb0395a604d94 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Mon, 26 Aug 2013 19:28:25 -0700 Subject: [PATCH 011/335] Fix typo in package description. --- packages/amplify/package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amplify/package.js b/packages/amplify/package.js index c808eda438..012777f8b1 100644 --- a/packages/amplify/package.js +++ b/packages/amplify/package.js @@ -1,5 +1,5 @@ Package.describe({ - summary: "API for Persistant Storage, PubSub and Request" + summary: "API for Persistent Storage, PubSub and Request" }); Package.on_use(function (api) { From bab5c1685da9de7bc7573b4806fde1828ea65c07 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 20 Aug 2013 10:52:45 -0700 Subject: [PATCH 012/335] Fix docs typo. Fixes #1297 --- docs/client/api.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client/api.html b/docs/client/api.html index cc75bdc559..5d30aec68c 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -2409,7 +2409,7 @@ from the call to `check` or `Match.test`. Examples: NonEmptyString = Match.Where(function (x) { check(x, String); return x.length > 0; - } + }); check(arg, NonEmptyString); {{/dtdd}} From 0e70184fd071271d590b150c41d14f3c99db4c7d Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Wed, 14 Aug 2013 13:57:02 -0700 Subject: [PATCH 013/335] fix typo. --- docs/client/concepts.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client/concepts.html b/docs/client/concepts.html index 9626162e93..127c9860f1 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -724,7 +724,7 @@ might have two or three (for example, a package that provides several classes that work together). You see only the exports of the packages that you use directly. If you -use package A, and package B uses package A, then you only see package +use package A, and package A uses package B, then you only see package A's exports. Package B's exports don't "leak" into your namespace just because you used package A. This keeps each namespace nice and tidy. Each app or package only sees their own globals plus the APIs of From f6b0bab5179623334306375caf1139b25fb202d8 Mon Sep 17 00:00:00 2001 From: Geoff Schmidt Date: Wed, 14 Aug 2013 13:38:43 -0700 Subject: [PATCH 014/335] Docs: CoffeeScript namespacing, other tweaks --- docs/client/concepts.html | 27 +++++++++++++------ docs/client/packages/coffeescript.html | 37 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/client/concepts.html b/docs/client/concepts.html index 127c9860f1..7376b18001 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -752,6 +752,17 @@ debugger, you've got two options: you can access `Package.email.Email.send` even from namespaces that don't use the `email` package directly. +When declaring functions, keep in mind that `function x () {}` is just +shorthard for `var x = function () {}` in JavaScript. Consider these +examples: + + // This is the same as 'var x = function () ...'. So x() is + // file-scope and can be called only from within this one file. + function x () { ... } + + // No 'var', so x() is package-scope and can be called from + // any file inside this app or package. + x = function () { ... } {{#note}} Technically speaking, globals in an app (as opposed to in a package) @@ -924,14 +935,14 @@ quick tips: to load before A. This is occasionally useful for resolving circular dependencies. -* It is also for one package to imply another package with - `api.imply`. If package A implies package B, then when someone - depends on package A, it's as if they also depended on package B as - well. In particular, they get B's exports. This can be used to - create umbrella packages such as `standard-app-packages` that are a - shortcut for pulling in a set of packages, or it can be helpful in - factoring common code out of a set of packages as with - `accounts-base`. +* The build system also supports package implication. If package A + implies package B, then it means that when someone depends on + package A, it's as if they also depended on package B as well. In + particular, they get B's exports. This is done with `api.imply` and + can be used to create umbrella packages such as + `standard-app-packages` that are a shortcut for pulling in a set of + packages, or it can be helpful in factoring common code out of a set + of packages as with `accounts-base`. * The build system understands the idea of native code and has a system of architecture names to ensure that packages that are diff --git a/docs/client/packages/coffeescript.html b/docs/client/packages/coffeescript.html index 08e09c3e9c..d01284cf4f 100644 --- a/docs/client/packages/coffeescript.html +++ b/docs/client/packages/coffeescript.html @@ -11,5 +11,42 @@ CoffeeScript is supported on both the client and the server. Files ending with `.coffee` or `.litcoffee` are automatically compiled to JavaScript. +### Namespacing and CoffeeScript + +Here's how CoffeeScript works with Meteor's namespacing. + +* Per the usual CoffeeScript convention, CoffeeScript variable are + file-scoped by default (visible only in the `.coffee` file where + they are defined.) + +* When writing a package, CoffeeScript-defined variables can be + exported like any other variable (see [Writing + Packages](#writingpackages)). Exporting a variable pulls it up to + package scope, meaning that it will be visible to all of the code in + your app or package (both `.js` and `.coffee` files). + +* Package-scope variables declared in `.js` files are visible in any + `.coffee` files in the same app or project. + +* There is no way to make a package-scope variable from a `.coffee` + file other than exporting it. We couldn't figure out a way to make + this fit naturally inside the CoffeeScript language. If you want to + use package-scope variables with CoffeeScript, one way is to make a + short `.js` file that declares all of your package-scope + variables. They can then be used and assigned to from `.coffee` + files. + +* If you want to share variables between `.coffee` files in the same + package, and don't want to separately declare them in a `.js` file, + we have an experimental feature that you may like. An object called + `share` is visible in CoffeeScript code and is shared across all + `.coffee` files in the same package. So, you can write `share.foo` + for a value that is shared between all CoffeeScript code in a + package, but doesn't escape that package. + +Heavy CoffeeScript users, please let us know how this arrangement +works for you, whether `share` is helpful for you, and anything else +you'd like to see changed. + {{/better_markdown}} From 6a4216f16fe2c469e63fe5876e2f733ef771389a Mon Sep 17 00:00:00 2001 From: David Glasser Date: Wed, 14 Aug 2013 16:52:39 -0700 Subject: [PATCH 015/335] Fix doc on how to get correct fibers. --- docs/client/concepts.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client/concepts.html b/docs/client/concepts.html index 7376b18001..f0c59d774f 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -842,7 +842,7 @@ created on. To run on a different platform, you'll need to rebuild the native packages included in the bundle. To do that, make sure you have `npm` available, and run the following: - $ cd bundle/server/node_modules + $ cd bundle/programs/server/node_modules $ rm -r fibers $ npm install fibers@1.0.1 {{/warning}} From fab6f3fe5d14d492b1a199bc401cdab1e596a10a Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Mon, 26 Aug 2013 20:30:41 -0700 Subject: [PATCH 016/335] First pass at history for 0.6.5.1 --- History.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/History.md b/History.md index 9ded11a003..0a00833214 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,25 @@ ## vNEXT +## v0.6.5.1 + +* Fix syntax errors on lines that end with a backslash. #1326 + +* Fix serving static files with special characters in their name. #1339 + +* Upgrade `esprima` JavaScript parser to fix bug parsing complex regexps. + +* Export `Spiderable` from `spiderable` package to allow users to set + `Spiderable.userAgentRegExps` to control what user agents are treated + as spiders. + +* Add EJSON to standard-app-packages. #1343 + +* Fix bug in d3 tab character parsing. + +* Fix regression when using Mongo ObjectIDs in Spark templates. + + ## v0.6.5 * New package system with package compiler and linker: From 61f84eb7e7296795bceb404efd5e2f3ddd174e0e Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Tue, 27 Aug 2013 20:17:59 -0700 Subject: [PATCH 017/335] banner and notices for 0.6.5.1 --- scripts/admin/banner.txt | 5 +++-- scripts/admin/notices.json | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/admin/banner.txt b/scripts/admin/banner.txt index 8947659e2e..f0422b945e 100644 --- a/scripts/admin/banner.txt +++ b/scripts/admin/banner.txt @@ -1,4 +1,5 @@ -=> Meteor 0.6.5: namespacing, modularity, new build system, source maps! +=> Meteor 0.6.5.1: a few bugfixes, including fixes for lines ending with + backslashes, filenames with with spaces, and tab characters in d3. This is being downloaded in the background. Update your project - to Meteor 0.6.5 by running 'meteor update'. + to Meteor 0.6.5.1 by running 'meteor update'. diff --git a/scripts/admin/notices.json b/scripts/admin/notices.json index 7eea43698d..176e5eb606 100644 --- a/scripts/admin/notices.json +++ b/scripts/admin/notices.json @@ -51,6 +51,9 @@ "https://docs.meteor.com/#writingpackages" ] }, + { + "release": "0.6.5.1" + }, { "release": "NEXT" } From cdb6f90b6617df84cb1cef58f5b911fcb1ee58f7 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 14 Aug 2013 17:03:45 -0700 Subject: [PATCH 018/335] Bump docs version to latest galaxy --- docs/.meteor/release | 2 +- docs/client/docs.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/.meteor/release b/docs/.meteor/release index ef5e445445..3f50c3b805 100644 --- a/docs/.meteor/release +++ b/docs/.meteor/release @@ -1 +1 @@ -0.6.5 +galaxy-appconfig-2 diff --git a/docs/client/docs.js b/docs/client/docs.js index dfa8773234..ece40199c3 100644 --- a/docs/client/docs.js +++ b/docs/client/docs.js @@ -1,5 +1,8 @@ Template.headline.release = function () { - return Meteor.release || "(checkout)"; + // XXX This is commented out because for now galaxy apps have to be on a + // different Meteor release that has a bug fix. + return "0.6.5"; + // return Meteor.release || "(checkout)"; }; From 5d4849d3615dd54bea836b60ca792f8b5dbe21d7 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Tue, 27 Aug 2013 20:28:56 -0700 Subject: [PATCH 019/335] bump docs version too. eventually this won't be needed. --- docs/client/docs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client/docs.js b/docs/client/docs.js index ece40199c3..c057ef71bd 100644 --- a/docs/client/docs.js +++ b/docs/client/docs.js @@ -1,7 +1,7 @@ Template.headline.release = function () { // XXX This is commented out because for now galaxy apps have to be on a // different Meteor release that has a bug fix. - return "0.6.5"; + return "0.6.5.1"; // return Meteor.release || "(checkout)"; }; From 16b8f0b0b6ff49533597d0ec63063d37808c02bd Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Wed, 28 Aug 2013 14:13:09 -0700 Subject: [PATCH 020/335] update examples for 0.6.5.1 --- examples/leaderboard/.meteor/release | 2 +- examples/parties/.meteor/release | 2 +- examples/todos/.meteor/release | 2 +- examples/wordplay/.meteor/release | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/leaderboard/.meteor/release b/examples/leaderboard/.meteor/release index ef5e445445..dd8bfff626 100644 --- a/examples/leaderboard/.meteor/release +++ b/examples/leaderboard/.meteor/release @@ -1 +1 @@ -0.6.5 +0.6.5.1 diff --git a/examples/parties/.meteor/release b/examples/parties/.meteor/release index ef5e445445..dd8bfff626 100644 --- a/examples/parties/.meteor/release +++ b/examples/parties/.meteor/release @@ -1 +1 @@ -0.6.5 +0.6.5.1 diff --git a/examples/todos/.meteor/release b/examples/todos/.meteor/release index ef5e445445..dd8bfff626 100644 --- a/examples/todos/.meteor/release +++ b/examples/todos/.meteor/release @@ -1 +1 @@ -0.6.5 +0.6.5.1 diff --git a/examples/wordplay/.meteor/release b/examples/wordplay/.meteor/release index ef5e445445..dd8bfff626 100644 --- a/examples/wordplay/.meteor/release +++ b/examples/wordplay/.meteor/release @@ -1 +1 @@ -0.6.5 +0.6.5.1 From e082d9745a1e6db128a905bfb5cca7167897c35d Mon Sep 17 00:00:00 2001 From: Mitar Date: Sat, 31 Aug 2013 10:03:41 -0700 Subject: [PATCH 021/335] Check for err.stack in logErr in wrapAsync. --- packages/meteor/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/meteor/helpers.js b/packages/meteor/helpers.js index 0804eff775..7edd152bcc 100644 --- a/packages/meteor/helpers.js +++ b/packages/meteor/helpers.js @@ -84,7 +84,7 @@ _.extend(Meteor, { var logErr = function (err) { if (err) return Meteor._debug("Exception in callback of async function", - err ? err.stack : err); + err.stack ? err.stack : err); }; // Pop off optional args that are undefined From ba34b2550bf4b73bb13e0ef79367ed18c79833c4 Mon Sep 17 00:00:00 2001 From: Andrew Wilcox Date: Tue, 27 Aug 2013 17:19:04 -0400 Subject: [PATCH 022/335] Chrome for iOS supports the appcache --- docs/client/packages/appcache.html | 3 ++- packages/appcache/appcache-server.js | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/client/packages/appcache.html b/docs/client/packages/appcache.html index fd7591618f..4f2885b189 100644 --- a/docs/client/packages/appcache.html +++ b/docs/client/packages/appcache.html @@ -44,7 +44,8 @@ You can also disable the application cache for specific browsers: }); The supported browsers that can be enabled or disabled are `android`, -`chrome`, `chromium`, `firefox`, `ie`, `mobileSafari` and `safari`. +`chrome`, `chromium`, `chromeMobileIOS`, `firefox`, `ie`, +`mobileSafari` and `safari`. Browsers limit the amount of data they will put in the application cache, which can vary due to factors such as how much disk space is diff --git a/packages/appcache/appcache-server.js b/packages/appcache/appcache-server.js index ce64327820..acd7006012 100644 --- a/packages/appcache/appcache-server.js +++ b/packages/appcache/appcache-server.js @@ -6,6 +6,7 @@ var knownBrowsers = [ 'android', 'chrome', 'chromium', + 'chromeMobileIOS', 'firefox', 'ie', 'mobileSafari', @@ -16,6 +17,7 @@ var browsersEnabledByDefault = [ 'android', 'chrome', 'chromium', + 'chromeMobileIOS', 'ie', 'mobileSafari', 'safari' From afa8afd9a993c742be6bf38f23bb0e7858eea8fb Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 21 Aug 2013 22:16:41 -0700 Subject: [PATCH 023/335] Keep track of open DDP connections by login token. --- packages/accounts-base/accounts_server.js | 10 +++--- packages/livedata/livedata_common.js | 11 ++++++ packages/livedata/livedata_server.js | 43 +++++++++++++++++++++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index cdcd995ace..4868b7c845 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -74,15 +74,17 @@ Meteor.methods({ var result = tryAllLoginHandlers(options); if (result !== null) { this.setUserId(result.id); - this._sessionData.loginToken = result.token; + this._setLoginToken(result.token); } return result; }, logout: function() { - if (this._sessionData.loginToken && this.userId) - removeLoginToken(this.userId, this._sessionData.loginToken); + var token = this._getLoginToken(); + if (token && this.userId) + removeLoginToken(this.userId, token); this.setUserId(null); + this._setLoginToken(null); } }); @@ -115,7 +117,7 @@ Accounts._generateStampedLoginToken = function () { return {token: Random.id(), when: (new Date)}; }; -removeLoginToken = function (userId, loginToken) { +var removeLoginToken = function (userId, loginToken) { Meteor.users.update(userId, { $pull: { "services.resume.loginTokens": { "token": loginToken } diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index b39b65e491..589465ccd9 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -29,6 +29,10 @@ MethodInvocation = function (options) { // reruns subscriptions this._setUserId = options.setUserId || function () {}; + // saves the login token so we can delete it later when the user logs out, is + // deleted, etc. + this._setLoginToken = options._setLoginToken || function () {}; + // Scratch data scoped to this connection (livedata_connection on the // client, livedata_session on the server). This is only used // internally, but we should have real and documented API for this @@ -48,6 +52,13 @@ _.extend(MethodInvocation.prototype, { throw new Error("Can't call setUserId in a method after calling unblock"); self.userId = userId; self._setUserId(userId); + }, + _setLoginToken: function (token) { + this._setLoginToken(token, this._sessionData.loginToken); + this._sessionData.loginToken = token; + }, + _getLoginToken: function (token) { + return this._sessionData.loginToken; } }); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 5c396c4a00..8e8602c9e8 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -594,9 +594,15 @@ _.extend(Session.prototype, { self._setUserId(userId); }; + var setLoginToken = function (newToken, oldToken) { + self._setLoginToken(newToken, oldToken); + }; + var invocation = new MethodInvocation({ isSimulation: false, - userId: self.userId, setUserId: setUserId, + userId: self.userId, + setUserId: setUserId, + _setLoginToken: setLoginToken, unblock: unblock, sessionData: self.sessionData }); @@ -651,6 +657,11 @@ _.extend(Session.prototype, { }); }, + _setLoginToken: function (newToken, oldToken) { + var self = this; + self.server._loginTokenChanged(self, newToken, oldToken); + }, + // Sets the current user id in all appropriate contexts and reruns // all subscriptions _setUserId: function(userId) { @@ -1026,6 +1037,12 @@ Server = function () { self.sessions = {}; // map from id to session + // Keeps track of the open connections associated with particular login + // tokens. Used for logging out all a user's open connections, expiring login + // tokens, etc. + self.sessionsByLoginToken = {}; + + self.stream_server = new StreamServer; self.stream_server.register(function (socket) { @@ -1269,17 +1286,26 @@ _.extend(Server.prototype, { var setUserId = function() { throw new Error("Can't call setUserId on a server initiated method call"); }; + var setLoginToken = function () { + throw new Error("Can't call _setLoginToken on a server " + + "initiated method call"); + }; var currentInvocation = DDP._CurrentInvocation.get(); if (currentInvocation) { userId = currentInvocation.userId; setUserId = function(userId) { currentInvocation.setUserId(userId); }; + setLoginToken = function (newToken, oldToken) { + currentInvocation._setLoginToken(newToken, oldToken); + }; } var invocation = new MethodInvocation({ isSimulation: false, - userId: userId, setUserId: setUserId, + userId: userId, + setUserId: setUserId, + _setLoginToken: setLoginToken, sessionData: self.sessionData }); try { @@ -1304,6 +1330,19 @@ _.extend(Server.prototype, { if (exception) throw exception; return result; + }, + + _loginTokenChanged: function (session, newToken, oldToken) { + if (oldToken) { + // Remove the session from the list of open sessions for the old token. + self.sessionsByLoginToken[oldToken] = _.without( + self.sessionsByLoginToken[oldToken], + session.id + ); + } + if (! _.has(self.sessionsByLoginToken, newToken)) + self.sessionsByLoginToken[newToken] = []; + self.sessionsByLoginToken[newToken].push(session.id); } }); From 7dfff264a325d96b861608588fbc40b9d2f2101a Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 21 Aug 2013 22:50:55 -0700 Subject: [PATCH 024/335] Add method that logs the user out everywhere. Might need some UI work; currently causes confusing error message "Couldn't find login token." --- packages/accounts-base/accounts_server.js | 17 ++++++++++++++++ packages/livedata/livedata_common.js | 3 +++ packages/livedata/livedata_server.js | 24 +++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 4868b7c845..53c209f5f9 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -85,6 +85,23 @@ Meteor.methods({ removeLoginToken(this.userId, token); this.setUserId(null); this._setLoginToken(null); + // XXX should close all connections open with this token? + }, + + // Nuke everything: delete all the user's tokens and close all open + // connections logged in as this user. XXX Should eventually get a fresh new + // token on the connection that called it and not get closed. + logoutAll: function () { + var user = Meteor.users.findOne(this.userId); + if (user) { + var tokens = user.services.resume.loginTokens; + Meteor.users.update(this.userId, { + $set: { "services.resume.loginTokens": [] } + }); + this._closeAllForTokens(_.map(tokens, function (token) { + return token.token; + })); + } } }); diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index 589465ccd9..93db53fd97 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -33,6 +33,9 @@ MethodInvocation = function (options) { // deleted, etc. this._setLoginToken = options._setLoginToken || function () {}; + // Closes all open connections associated with the given tokens. + this._closeAllForTokens = options._closeAllForTokens || function () {}; + // Scratch data scoped to this connection (livedata_connection on the // client, livedata_session on the server). This is only used // internally, but we should have real and documented API for this diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 8e8602c9e8..d4cc7b79db 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -598,11 +598,16 @@ _.extend(Session.prototype, { self._setLoginToken(newToken, oldToken); }; + var closeAll = function (tokens) { + self._closeAllForTokens(tokens); + }; + var invocation = new MethodInvocation({ isSimulation: false, userId: self.userId, setUserId: setUserId, _setLoginToken: setLoginToken, + _closeAllForTokens: closeAll, unblock: unblock, sessionData: self.sessionData }); @@ -662,6 +667,11 @@ _.extend(Session.prototype, { self.server._loginTokenChanged(self, newToken, oldToken); }, + _closeAllForTokens: function (tokens) { + var self = this; + self.server._closeAllForTokens(tokens); + }, + // Sets the current user id in all appropriate contexts and reruns // all subscriptions _setUserId: function(userId) { @@ -1333,6 +1343,7 @@ _.extend(Server.prototype, { }, _loginTokenChanged: function (session, newToken, oldToken) { + var self = this; if (oldToken) { // Remove the session from the list of open sessions for the old token. self.sessionsByLoginToken[oldToken] = _.without( @@ -1343,6 +1354,19 @@ _.extend(Server.prototype, { if (! _.has(self.sessionsByLoginToken, newToken)) self.sessionsByLoginToken[newToken] = []; self.sessionsByLoginToken[newToken].push(session.id); + }, + + _closeAllForTokens: function (tokens) { + var self = this; + _.each(tokens, function (token) { + if (_.has(self.sessionsByLoginToken, token)) { + _.each(self.sessionsByLoginToken[token], function (sessionId) { + self.sessions[sessionId].cleanup(); + self.sessions[sessionId].destroy(); + delete self.sessions[sessionId]; + }); + } + }); } }); From 3403b31c425802d21392755cece6dcbbacb01f96 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 22 Aug 2013 22:36:34 -0700 Subject: [PATCH 025/335] Preserve the connection that called logoutAll. --- packages/accounts-base/accounts_client.js | 12 ++++++++++++ packages/accounts-base/accounts_server.js | 14 +++++++++++--- packages/livedata/livedata_server.js | 16 ++++++++++------ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 3dd0c5cd35..ceb9affd89 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -174,6 +174,18 @@ Meteor.logout = function (callback) { }); }; +Meteor._logoutAll = function (callback) { + Meteor.apply('_logoutAll', [], {wait: true}, function (error, result) { + if (error) { + callback && callback(error); + } else { + // The method should return a new valid token that we should start using. + makeClientLoggedIn(Meteor.userId(), result.token); + callback && callback(); + } + }); +}; + /// /// LOGIN SERVICES /// diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 53c209f5f9..e69e8dead9 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -89,9 +89,8 @@ Meteor.methods({ }, // Nuke everything: delete all the user's tokens and close all open - // connections logged in as this user. XXX Should eventually get a fresh new - // token on the connection that called it and not get closed. - logoutAll: function () { + // connections logged in as this user. + _logoutAll: function () { var user = Meteor.users.findOne(this.userId); if (user) { var tokens = user.services.resume.loginTokens; @@ -101,6 +100,15 @@ Meteor.methods({ this._closeAllForTokens(_.map(tokens, function (token) { return token.token; })); + + var newToken = Accounts._generateStampedLoginToken(); + Meteor.users.update(this.userId, { + $push: { + "services.resume.loginTokens": newToken + } + }); + this._setLoginToken(newToken); + return newToken; } } }); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index d4cc7b79db..33e30b1a9b 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -599,7 +599,7 @@ _.extend(Session.prototype, { }; var closeAll = function (tokens) { - self._closeAllForTokens(tokens); + self._closeAllForTokens(tokens, [self.id]); }; var invocation = new MethodInvocation({ @@ -669,7 +669,7 @@ _.extend(Session.prototype, { _closeAllForTokens: function (tokens) { var self = this; - self.server._closeAllForTokens(tokens); + self.server._closeAllForTokens(tokens, [self.id]); }, // Sets the current user id in all appropriate contexts and reruns @@ -1356,14 +1356,18 @@ _.extend(Server.prototype, { self.sessionsByLoginToken[newToken].push(session.id); }, - _closeAllForTokens: function (tokens) { + // Close all open sessions associated with any of the tokens in `tokens`, + // except for sessions with ids in `excludeSessions`. + _closeAllForTokens: function (tokens, excludeSessions) { var self = this; _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { _.each(self.sessionsByLoginToken[token], function (sessionId) { - self.sessions[sessionId].cleanup(); - self.sessions[sessionId].destroy(); - delete self.sessions[sessionId]; + if (_.indexOf(excludeSessions, sessionId) === -1) { + self.sessions[sessionId].cleanup(); + self.sessions[sessionId].destroy(); + delete self.sessions[sessionId]; + } }); } }); From 12c0d8ef5e3ab64908d52c8fe32f48f1e96133d5 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 23 Aug 2013 17:46:03 -0700 Subject: [PATCH 026/335] Rename to Meteor._logoutAllOthers --- packages/accounts-base/accounts_client.js | 4 ++-- packages/accounts-base/accounts_server.js | 5 +++-- packages/livedata/livedata_server.js | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index ceb9affd89..f538d16388 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -174,8 +174,8 @@ Meteor.logout = function (callback) { }); }; -Meteor._logoutAll = function (callback) { - Meteor.apply('_logoutAll', [], {wait: true}, function (error, result) { +Meteor._logoutAllOthers = function (callback) { + Meteor.apply('_logoutAllOthers', [], {wait: true}, function (error, result) { if (error) { callback && callback(error); } else { diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index e69e8dead9..a66f795947 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -89,8 +89,9 @@ Meteor.methods({ }, // Nuke everything: delete all the user's tokens and close all open - // connections logged in as this user. - _logoutAll: function () { + // connections logged in as this user, except this connection. Returns a fresh + // new login token that this client can use. + _logoutAllOthers: function () { var user = Meteor.users.findOne(this.userId); if (user) { var tokens = user.services.resume.loginTokens; diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 33e30b1a9b..5c4a99ea4c 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -598,6 +598,7 @@ _.extend(Session.prototype, { self._setLoginToken(newToken, oldToken); }; + // Closes all sessions associated with these tokens except this one. var closeAll = function (tokens) { self._closeAllForTokens(tokens, [self.id]); }; From 4b7f052ce06e9fac45ca23c14642fd9d925d6619 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 23 Aug 2013 18:05:23 -0700 Subject: [PATCH 027/335] Wait 10 seconds before closing other logged-in sessions. Gives other tabs sharing a token with the caller time to find the new token in localStorage. This is sort of a hack for now; one possibility for making it less hacky is to add a DDP disconnect message to allow the server to tell clients that they are being disconnected but can reconnect with the provided token. --- packages/accounts-base/accounts_server.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index a66f795947..a50b335fa7 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -92,23 +92,29 @@ Meteor.methods({ // connections logged in as this user, except this connection. Returns a fresh // new login token that this client can use. _logoutAllOthers: function () { - var user = Meteor.users.findOne(this.userId); + var self = this; + var user = Meteor.users.findOne(self.userId); if (user) { var tokens = user.services.resume.loginTokens; - Meteor.users.update(this.userId, { + Meteor.users.update(self.userId, { $set: { "services.resume.loginTokens": [] } }); - this._closeAllForTokens(_.map(tokens, function (token) { - return token.token; - })); + // Wait 10 seconds before closing the connections. This is to give other + // clients using our token a chance to find a new token in localStorage so + // that when they get disconnected they reconnect with a valid token. + Meteor.setTimeout(function () { + self._closeAllForTokens(_.map(tokens, function (token) { + return token.token; + })); + }, 10*1000); var newToken = Accounts._generateStampedLoginToken(); - Meteor.users.update(this.userId, { + Meteor.users.update(self.userId, { $push: { "services.resume.loginTokens": newToken } }); - this._setLoginToken(newToken); + self._setLoginToken(newToken); return newToken; } } From 1887d6960b14b90e4f0b47c8b332f3d050c67444 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 12:25:56 -0700 Subject: [PATCH 028/335] Expire login tokens periodically. When a login token expires, all open connections associated with that token will be closed. It will be up to client code to avoid trying to connect with a login token that is set to expire soon. --- packages/accounts-base/accounts_server.js | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index a50b335fa7..6671ec61bc 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -125,6 +125,11 @@ Meteor.methods({ /// /// support reconnecting using a meteor login token +// how often (in seconds) we check for expired tokens +var EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes +// how long (in seconds) until a login token expires +var TOKEN_LIFETIME = 604800; // one week + // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { if (!options.resume) @@ -134,6 +139,7 @@ Accounts.registerLoginHandler(function(options) { var user = Meteor.users.findOne({ "services.resume.loginTokens.token": ""+options.resume }); + if (!user) throw new Meteor.Error(403, "Couldn't find login token"); @@ -157,6 +163,38 @@ var removeLoginToken = function (userId, loginToken) { }); }; +// Deletes expired tokens from the database and closes all open connections +// associated with these tokens. +expireTokens = function () { + var oldestValidDate = new Date(new Date() - TOKEN_LIFETIME * 1000); + var usersWithExpiredTokens = Meteor.users.find({ + "services.resume.loginTokens.when": { $lt: oldestValidDate } + }); + + var oldTokens = []; + usersWithExpiredTokens.forEach(function (user) { + _.each(user.services.resume.loginTokens, function (token) { + if (token.when < oldestValidDate) + oldTokens.push(token.token); + }); + }); + + Meteor.users.update({ + "services.resume.loginTokens.when": { $lt: oldestValidDate } + }, { + $pull: { + "services.resume.loginTokens": { + when: { $lt: oldestValidDate } + } + } + }); + + Meteor.server._closeAllForTokens(oldTokens); +}; + +Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); +Meteor.setInterval(expireTokens, EXPIRE_TOKENS_INTERVAL); + /// /// CREATE USER HOOKS From d39726d737f5c4583c88005fc5f6e7189ad8e756 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 13:23:17 -0700 Subject: [PATCH 029/335] Store login token on livedata session. Allows us to remove sessions from sessionsByLoginToken when they are destroyed. --- packages/livedata/livedata_common.js | 2 +- packages/livedata/livedata_server.js | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index 93db53fd97..dc2510c353 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -57,7 +57,7 @@ _.extend(MethodInvocation.prototype, { self._setUserId(userId); }, _setLoginToken: function (token) { - this._setLoginToken(token, this._sessionData.loginToken); + this._setLoginToken(token); this._sessionData.loginToken = token; }, _getLoginToken: function (token) { diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 5c4a99ea4c..bbceeaf7eb 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -238,6 +238,7 @@ var Session = function (server, version) { self._universalSubs = []; self.userId = null; + self.loginToken = null; // Per-connection scratch area. This is only used internally, but we // should have real and documented API for this sort of thing someday. @@ -594,8 +595,8 @@ _.extend(Session.prototype, { self._setUserId(userId); }; - var setLoginToken = function (newToken, oldToken) { - self._setLoginToken(newToken, oldToken); + var setLoginToken = function (newToken) { + self._setLoginToken(newToken); }; // Closes all sessions associated with these tokens except this one. @@ -663,8 +664,10 @@ _.extend(Session.prototype, { }); }, - _setLoginToken: function (newToken, oldToken) { + _setLoginToken: function (newToken) { var self = this; + var oldToken = self.loginToken; + self.loginToken = newToken; self.server._loginTokenChanged(self, newToken, oldToken); }, @@ -1127,6 +1130,11 @@ Server = function () { } }); _.each(destroyedIds, function (id) { + var session = self.sessions[id]; + self.sessionsByLoginToken[session.loginToken] = _.without( + self.sessionsByLoginToken[session.loginToken], + id + ); delete self.sessions[id]; }); }, 1 * 60 * 1000); @@ -1307,8 +1315,8 @@ _.extend(Server.prototype, { setUserId = function(userId) { currentInvocation.setUserId(userId); }; - setLoginToken = function (newToken, oldToken) { - currentInvocation._setLoginToken(newToken, oldToken); + setLoginToken = function (newToken) { + currentInvocation._setLoginToken(newToken); }; } From d84334a34b154f391a158f9709adda63c0f2fe5b Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 15:44:59 -0700 Subject: [PATCH 030/335] Make client aware of token expiration times. accounts-password will not try to auto-login with (and will clear) a login token that is going to expire within an hour, to try to avoid abrupt disconnects from an expiring token. Login handlers return a new tokenExpires field to help the client do this. Made tokenExpires a separate field on the login handlers' result object instead of just returning the whole token (token + when fields) in the result to avoid breaking code that assumes that login handlers return a string as the token field. The tokenExpires field is optional, so other login handlers that don't set it aren't broken. --- packages/accounts-base/accounts_client.js | 8 +++--- packages/accounts-base/accounts_common.js | 6 +++++ packages/accounts-base/accounts_server.js | 25 +++++++++++++++---- packages/accounts-base/localstorage_token.js | 17 ++++++++++++- packages/accounts-password/password_server.js | 25 ++++++++++++++++--- 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index f538d16388..2b3c2fad8c 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -139,7 +139,7 @@ Accounts.callLoginMethod = function (options) { } // Make the client logged in. (The user data should already be loaded!) - makeClientLoggedIn(result.id, result.token); + makeClientLoggedIn(result.id, result.token, result.tokenExpires); options.userCallback(); }; @@ -158,8 +158,8 @@ makeClientLoggedOut = function() { Meteor.connection.onReconnect = null; }; -makeClientLoggedIn = function(userId, token) { - storeLoginToken(userId, token); +makeClientLoggedIn = function(userId, token, tokenExpires { + storeLoginToken(userId, token, tokenExpires); Meteor.connection.setUserId(userId); }; @@ -180,7 +180,7 @@ Meteor._logoutAllOthers = function (callback) { callback && callback(error); } else { // The method should return a new valid token that we should start using. - makeClientLoggedIn(Meteor.userId(), result.token); + makeClientLoggedIn(Meteor.userId(), result.token, result.tokenExpires); callback && callback(); } }); diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 0db0271db9..b6ab826002 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -66,3 +66,9 @@ Accounts.LoginCancelledError.numericError = 0x8acdc2f; Accounts.LoginCancelledError.prototype = new Error(); Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; +// how long (in seconds) until a login token expires +// XXX maybe should be configurable +TOKEN_LIFETIME = 604800; // one week +Accounts._tokenExpiration = function (when) { + return new Date(when.getTime() + TOKEN_LIFETIME * 1000); +}; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 6671ec61bc..77044a5183 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -34,7 +34,11 @@ Meteor.user = function () { // @param handler {Function} A function that receives an options object // (as passed as an argument to the `login` method) and returns one of: // - `undefined`, meaning don't handle; -// - {id: userId, token: *}, if the user logged in successfully. +// - {id: userId, token: *, tokenExpires: *}, if the user logged in +// successfully. tokenExpires is optional and intends to provide a hint to the +// client as to when the token will expire. If not provided, the client +// will assume the token expires TOKEN_LIFETIME seconds from when it receives +// it. // - throw an error, if the user failed to log in. // Accounts.registerLoginHandler = function(handler) { @@ -115,7 +119,10 @@ Meteor.methods({ } }); self._setLoginToken(newToken); - return newToken; + return { + token: newToken.token, + tokenExpires: Accounts._tokenExpiration(newToken.when) + }; } } }); @@ -127,8 +134,6 @@ Meteor.methods({ // how often (in seconds) we check for expired tokens var EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes -// how long (in seconds) until a login token expires -var TOKEN_LIFETIME = 604800; // one week // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { @@ -143,8 +148,13 @@ Accounts.registerLoginHandler(function(options) { if (!user) throw new Meteor.Error(403, "Couldn't find login token"); + var token = _.find(user.services.resume.loginTokens, function (token) { + return token.token === options.resume; + }); + return { token: options.resume, + tokenExpires: Accounts._tokenExpiration(token.when), id: user._id }; }); @@ -236,6 +246,7 @@ Accounts.insertUserDoc = function (options, user) { if (options.generateLoginToken) { var stampedToken = Accounts._generateStampedLoginToken(); result.token = stampedToken.token; + result.tokenExpires = Accounts._tokenExpiration(stampedToken.when); Meteor._ensure(user, 'services', 'resume'); if (_.has(user.services.resume, 'loginTokens')) user.services.resume.loginTokens.push(stampedToken); @@ -352,7 +363,11 @@ Accounts.updateOrCreateUserFromExternalService = function( user._id, {$set: setAttrs, $push: {'services.resume.loginTokens': stampedToken}}); - return {token: stampedToken.token, id: user._id}; + return { + token: stampedToken.token, + id: user._id, + tokenExpires: Accounts._tokenExpiration(stampedToken.when) + }; } else { // Create a new user with the service data. Pass other options through to // insertUserDoc. diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 91205a6742..46bea22549 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -4,6 +4,9 @@ // browser. var lastLoginTokenWhenPolled; +// We don't try to auto-login with a token that is going to expire within +// MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. +var MIN_TOKEN_LIFETIME = 3600; // one hour // Login with a Meteor access token. This is the only public function // here. @@ -27,6 +30,7 @@ Accounts._enableAutoLogin = function () { // Key names to use in localStorage var loginTokenKey = "Meteor.loginToken"; +var loginTokenExpiresKey = "Meteor.loginTokenExpires"; var userIdKey = "Meteor.userId"; // Call this from the top level of the test file for any test that does @@ -37,9 +41,12 @@ Accounts._isolateLoginTokenForTest = function () { userIdKey = userIdKey + Random.id(); }; -storeLoginToken = function(userId, token) { +storeLoginToken = function(userId, token, tokenExpires) { Meteor._localStorage.setItem(userIdKey, userId); Meteor._localStorage.setItem(loginTokenKey, token); + if (! tokenExpires) + tokenExpires = Accounts._tokenExpiration(new Date()); + Meteor._localStorage.setItem(loginTokenExpiresKey, tokenExpires); // to ensure that the localstorage poller doesn't end up trying to // connect a second time @@ -49,6 +56,7 @@ storeLoginToken = function(userId, token) { unstoreLoginToken = function() { Meteor._localStorage.removeItem(userIdKey); Meteor._localStorage.removeItem(loginTokenKey); + Meteor._localStorage.removeItem(loginTokenExpiresKey); // to ensure that the localstorage poller doesn't end up trying to // connect a second time @@ -66,6 +74,12 @@ var storedUserId = function() { return Meteor._localStorage.getItem(userIdKey); }; +var unstoreLoginTokenIfExpiresSoon = function () { + var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey); + if (tokenExpires && + new Date() > (new Date(tokenExpires) - MIN_TOKEN_LIFETIME * 1000)) + unstoreLoginToken(); +}; /// /// AUTO-LOGIN @@ -74,6 +88,7 @@ var storedUserId = function() { if (autoLoginEnabled) { // Immediately try to log in via local storage, so that any DDP // messages are sent after we have established our user account + unstoreLoginTokenIfExpiresSoon(); var token = storedLoginToken(); if (token) { // On startup, optimistically present us as logged in while the diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 58782a7c6b..1031e2fbf4 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -98,7 +98,12 @@ Accounts.registerLoginHandler(function (options) { Meteor.users.update( userId, {$push: {'services.resume.loginTokens': stampedLoginToken}}); - return {token: stampedLoginToken.token, id: userId, HAMK: serialized.HAMK}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), + id: userId, + HAMK: serialized.HAMK + }; }); // Handler to login with plaintext password. @@ -137,7 +142,11 @@ Accounts.registerLoginHandler(function (options) { Meteor.users.update( user._id, {$push: {'services.resume.loginTokens': stampedLoginToken}}); - return {token: stampedLoginToken.token, id: user._id}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpires(stampedLoginToken.when), + id: user._id + }; }); @@ -321,7 +330,11 @@ Meteor.methods({resetPassword: function (token, newVerifier) { }); this.setUserId(user._id); - return {token: stampedLoginToken.token, id: user._id}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), + id: user._id + }; }}); /// @@ -408,7 +421,11 @@ Meteor.methods({verifyEmail: function (token) { $push: {'services.resume.loginTokens': stampedLoginToken}}); this.setUserId(user._id); - return {token: stampedLoginToken.token, id: user._id}; + return { + token: stampedLoginToken.token, + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), + id: user._id + }; }}); From 2ebdeb0d957a6e5b1b150061d7c83fd2ac682aab Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 16:08:53 -0700 Subject: [PATCH 031/335] Make token expiration times configurable via Accounts.config. --- packages/accounts-base/accounts_client.js | 2 +- packages/accounts-base/accounts_common.js | 13 ++++++++++--- packages/accounts-base/accounts_server.js | 18 +++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 2b3c2fad8c..a153c7d9ee 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -158,7 +158,7 @@ makeClientLoggedOut = function() { Meteor.connection.onReconnect = null; }; -makeClientLoggedIn = function(userId, token, tokenExpires { +makeClientLoggedIn = function(userId, token, tokenExpires) { storeLoginToken(userId, token, tokenExpires); Meteor.connection.setUserId(userId); }; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index b6ab826002..c63adbd18a 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -19,10 +19,15 @@ Accounts._options = {}; // client signups. // - forbidClientAccountCreation {Boolean} // Do not allow clients to create accounts directly. +// - _tokenLifetime {Number} +// Seconds until a login token expires. +// - _tokenExpirationInterval {Number} +// How often (in seconds) to check for expired tokens // Accounts.config = function(options) { // validate option keys - var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation"]; + var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", + "_tokenLifetime", "_tokenExpirationInterval"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); @@ -68,7 +73,9 @@ Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; // how long (in seconds) until a login token expires // XXX maybe should be configurable -TOKEN_LIFETIME = 604800; // one week +DEFAULT_TOKEN_LIFETIME = 604800; // one week Accounts._tokenExpiration = function (when) { - return new Date(when.getTime() + TOKEN_LIFETIME * 1000); + var tokenLifetime = Accounts._options._tokenLifetime || + DEFAULT_TOKEN_LIFETIME; + return new Date(when.getTime() + tokenLifetime * 1000); }; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 77044a5183..94123db9f3 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -36,9 +36,9 @@ Meteor.user = function () { // - `undefined`, meaning don't handle; // - {id: userId, token: *, tokenExpires: *}, if the user logged in // successfully. tokenExpires is optional and intends to provide a hint to the -// client as to when the token will expire. If not provided, the client -// will assume the token expires TOKEN_LIFETIME seconds from when it receives -// it. +// client as to when the token will expire. If not provided, the client will +// call Accounts._tokenExpiration, passing it the date that it received the +// token. // - throw an error, if the user failed to log in. // Accounts.registerLoginHandler = function(handler) { @@ -133,7 +133,7 @@ Meteor.methods({ /// support reconnecting using a meteor login token // how often (in seconds) we check for expired tokens -var EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes +var DEFAULT_EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { @@ -175,8 +175,10 @@ var removeLoginToken = function (userId, loginToken) { // Deletes expired tokens from the database and closes all open connections // associated with these tokens. -expireTokens = function () { - var oldestValidDate = new Date(new Date() - TOKEN_LIFETIME * 1000); +// Exported for tests. +var expireTokens = Accounts._expireTokens = function () { + var tokenLifetime = Accounts._options.tokenLifetime || DEFAULT_TOKEN_LIFETIME; + var oldestValidDate = new Date(new Date() - tokenLifetime * 1000); var usersWithExpiredTokens = Meteor.users.find({ "services.resume.loginTokens.when": { $lt: oldestValidDate } }); @@ -203,7 +205,9 @@ expireTokens = function () { }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); -Meteor.setInterval(expireTokens, EXPIRE_TOKENS_INTERVAL); +var expireInterval = Accounts._options.tokenExpirationInterval || + DEFAULT_EXPIRE_TOKENS_INTERVAL; +Meteor.setInterval(expireTokens, expireInterval * 1000); /// From 8621c18bc1f47723e40826322bb327ae2ae3aba2 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 29 Aug 2013 18:46:58 -0700 Subject: [PATCH 032/335] Add token expiration test and fix bugs so it passes. Also make all token-expiration-related times configurable via Accounts.config. --- packages/accounts-base/accounts_client.js | 30 +++++++++------ packages/accounts-base/accounts_common.js | 19 +++++++++- packages/accounts-base/accounts_server.js | 22 +++++++---- packages/accounts-base/localstorage_token.js | 6 +-- packages/accounts-password/password_server.js | 2 +- packages/accounts-password/password_tests.js | 38 ++++++++++++++++++- packages/livedata/livedata_server.js | 13 +++++-- 7 files changed, 97 insertions(+), 33 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index a153c7d9ee..3c48a6c9a4 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -93,18 +93,24 @@ Accounts.callLoginMethod = function (options) { } else { Meteor.connection.onReconnect = function() { reconnected = true; - Accounts.callLoginMethod({ - methodArguments: [{resume: result.token}], - // Reconnect quiescence ensures that the user doesn't see an - // intermediate state before the login method finishes. So we don't - // need to show a logging-in animation. - _suppressLoggingIn: true, - userCallback: function (error) { - if (error) { - makeClientLoggedOut(); - } - options.userCallback(error); - }}); + // XXX A DDP disconnect message would be helpful here, to know if our + // connection got closed because of an expired token. + if (! result.tokenExpires) + result.tokenExpires = Accounts._tokenExpiration(new Date()); + if (! Accounts._tokenExpiresSoon(result.tokenExpires)) { + Accounts.callLoginMethod({ + methodArguments: [{resume: result.token}], + // Reconnect quiescence ensures that the user doesn't see an + // intermediate state before the login method finishes. So we don't + // need to show a logging-in animation. + _suppressLoggingIn: true, + userCallback: function (error) { + if (error) { + makeClientLoggedOut(); + } + options.userCallback(error); + }}); + } }; } }; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index c63adbd18a..ffbd10ad57 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -23,11 +23,15 @@ Accounts._options = {}; // Seconds until a login token expires. // - _tokenExpirationInterval {Number} // How often (in seconds) to check for expired tokens +// - _minTokenLifetime {Number} +// The minimum number of seconds until a token expires in order for the +// client to be willing to connect with that token. // Accounts.config = function(options) { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", - "_tokenLifetime", "_tokenExpirationInterval"]; + "_tokenLifetime", "_tokenExpirationInterval", + "_minTokenLifetime"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); @@ -41,6 +45,8 @@ Accounts.config = function(options) { throw new Error("Can't set `" + key + "` more than once"); } else { Accounts._options[key] = options[key]; + if (key === "_tokenExpirationInterval" && Meteor.isServer) + initExpireTokenInterval(); } } }); @@ -72,10 +78,19 @@ Accounts.LoginCancelledError.prototype = new Error(); Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; // how long (in seconds) until a login token expires -// XXX maybe should be configurable DEFAULT_TOKEN_LIFETIME = 604800; // one week +// We don't try to auto-login with a token that is going to expire within +// MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. +var DEFAULT_MIN_TOKEN_LIFETIME = 3600; // one hour + Accounts._tokenExpiration = function (when) { var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; return new Date(when.getTime() + tokenLifetime * 1000); }; + +Accounts._tokenExpiresSoon = function (when) { + var minLifetime = Accounts._options._minTokenLifetime || + DEFAULT_MIN_TOKEN_LIFETIME; + return new Date() > (new Date(when) - minLifetime * 1000); +}; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 94123db9f3..5d47e83cda 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -175,9 +175,8 @@ var removeLoginToken = function (userId, loginToken) { // Deletes expired tokens from the database and closes all open connections // associated with these tokens. -// Exported for tests. -var expireTokens = Accounts._expireTokens = function () { - var tokenLifetime = Accounts._options.tokenLifetime || DEFAULT_TOKEN_LIFETIME; +var expireTokens = function () { + var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; var oldestValidDate = new Date(new Date() - tokenLifetime * 1000); var usersWithExpiredTokens = Meteor.users.find({ "services.resume.loginTokens.when": { $lt: oldestValidDate } @@ -186,6 +185,8 @@ var expireTokens = Accounts._expireTokens = function () { var oldTokens = []; usersWithExpiredTokens.forEach(function (user) { _.each(user.services.resume.loginTokens, function (token) { + if (typeof token.when === "number") + token.when = new Date(token.when); if (token.when < oldestValidDate) oldTokens.push(token.token); }); @@ -199,16 +200,21 @@ var expireTokens = Accounts._expireTokens = function () { when: { $lt: oldestValidDate } } } - }); + }, { multi: true }); Meteor.server._closeAllForTokens(oldTokens); }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); -var expireInterval = Accounts._options.tokenExpirationInterval || - DEFAULT_EXPIRE_TOKENS_INTERVAL; -Meteor.setInterval(expireTokens, expireInterval * 1000); - +var expireTokenInterval; +initExpireTokenInterval = function () { + if (expireTokenInterval) + Meteor.clearInterval(expireTokenInterval); + var expirePeriod = Accounts._options._tokenExpirationInterval || + DEFAULT_EXPIRE_TOKENS_INTERVAL; + expireTokenInterval = Meteor.setInterval(expireTokens, expirePeriod * 1000); +}; +initExpireTokenInterval(); /// /// CREATE USER HOOKS diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 46bea22549..023913cbfa 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -4,9 +4,6 @@ // browser. var lastLoginTokenWhenPolled; -// We don't try to auto-login with a token that is going to expire within -// MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. -var MIN_TOKEN_LIFETIME = 3600; // one hour // Login with a Meteor access token. This is the only public function // here. @@ -76,8 +73,7 @@ var storedUserId = function() { var unstoreLoginTokenIfExpiresSoon = function () { var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey); - if (tokenExpires && - new Date() > (new Date(tokenExpires) - MIN_TOKEN_LIFETIME * 1000)) + if (tokenExpires && Accounts._tokenExpiresSoon(tokenExpires)) unstoreLoginToken(); }; diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 1031e2fbf4..1771cb1e91 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -144,7 +144,7 @@ Accounts.registerLoginHandler(function (options) { return { token: stampedLoginToken.token, - tokenExpires: Accounts._tokenExpires(stampedLoginToken.when), + tokenExpires: Accounts._tokenExpiration(stampedLoginToken.when), id: user._id }; }); diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 3cfec9511f..0195ee1b18 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1,3 +1,9 @@ +Accounts.config({ + _tokenLifetime: 5, + _tokenExpirationInterval: 5, + _minTokenLifetime: 1 +}); + if (Meteor.isClient) (function () { // XXX note, only one test can do login/logout things at once! for @@ -255,6 +261,7 @@ if (Meteor.isClient) (function () { test.equal(result, null); })); }, + logoutStep, function(test, expect) { var expectLoginError = expect(function (err) { test.isTrue(err); @@ -267,8 +274,35 @@ if (Meteor.isClient) (function () { Meteor.loginWithToken(token, expectLoginError); }); }); - } - + }, + logoutStep, + function(test, expect) { + // Test that login tokens get expired. We should get logged out when a + // token expires, and not be able to log in again with the same token. + var expectLoggedOut = expect(function () { + test.equal(Meteor.user(), undefined); + }); + var expectLoginError = expect(function (err) { + test.isTrue(err); + }); + var expectNoError = expect(function (err) { + test.equal(err, undefined); + }); + var expectToken = expect(function (token) { + test.isTrue(token); + }); + var token; + Meteor.loginWithPassword(username, password2, function (error) { + expectNoError(error); + token = Accounts._storedLoginToken(); + }); + Meteor.setTimeout(function () { + expectToken(token); + expectLoggedOut(); + Meteor.loginWithToken(token, expectLoginError); + }, 10*1000); + }, + logoutStep ]); }) (); diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index bbceeaf7eb..2942736a22 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -238,7 +238,6 @@ var Session = function (server, version) { self._universalSubs = []; self.userId = null; - self.loginToken = null; // Per-connection scratch area. This is only used internally, but we // should have real and documented API for this sort of thing someday. @@ -666,8 +665,8 @@ _.extend(Session.prototype, { _setLoginToken: function (newToken) { var self = this; - var oldToken = self.loginToken; - self.loginToken = newToken; + var oldToken = self.sessionData.loginToken; + self.sessionData.loginToken = newToken; self.server._loginTokenChanged(self, newToken, oldToken); }, @@ -1371,13 +1370,21 @@ _.extend(Server.prototype, { var self = this; _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { + var destroyedIds = []; _.each(self.sessionsByLoginToken[token], function (sessionId) { if (_.indexOf(excludeSessions, sessionId) === -1) { self.sessions[sessionId].cleanup(); self.sessions[sessionId].destroy(); delete self.sessions[sessionId]; + destroyedIds.push(sessionId); } }); + self.sessionsByLoginToken[token] = _.filter( + self.sessionsByLoginToken[token], + function (sessionId) { + return _.indexOf(destroyedIds, sessionId) === -1; + } + ); } }); } From 10d49451d949919437dcccbfe4db5a81ccb27a49 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 30 Aug 2013 18:06:49 -0700 Subject: [PATCH 033/335] Add experimental "disconnected" DDP message. Client uses this to unset user id when a forced logout happens. --- packages/livedata/DDP.md | 12 ++++++ packages/livedata/livedata_connection.js | 11 ++++++ packages/livedata/livedata_server.js | 48 +++++++++++++++++------- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/livedata/DDP.md b/packages/livedata/DDP.md index dc59ece7c9..2ef93578e3 100644 --- a/packages/livedata/DDP.md +++ b/packages/livedata/DDP.md @@ -222,6 +222,18 @@ The error message contains the following fields: * `offendingMessage`: if the original message parsed properly, it is included here +## Disconnection + +### Messages: + + * `disconnected` (server->client) + - `reason`: string ("logged_out" or "token_expired") + +### Procedure: + + * Before the server disconnects a client, it can optionally send a disconnected + message to indicate the cause of the disconnect. + ## Appendix: EJSON EJSON is a way of embedding more than the built-in JSON types in JSON. It diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index 04fb380f90..68ea20ced0 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -209,6 +209,8 @@ var Connection = function (url, options) { self._livedata_nosub(msg); else if (msg.msg === 'result') self._livedata_result(msg); + else if (msg.msg === 'disconnected') + self._livedata_disconnected(msg); else if (msg.msg === 'error') self._livedata_error(msg); else @@ -1319,6 +1321,15 @@ _.extend(Connection.prototype, { }); }, + _livedata_disconnected: function (msg) { + var self = this; + var reason = msg.reason; + if (reason === "logged_out" || reason === "token_expired") { + self.setUserId(null); + self.onReconnect = null; + } + }, + _livedata_error: function (msg) { Meteor._debug("Received error from server: ", msg.reason); if (msg.offendingMessage) diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 2942736a22..c4bf1b06cf 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -452,6 +452,12 @@ _.extend(Session.prototype, { self.send(msg); }, + // Send a DDP disconnected message. + sendDisconnected: function (reason) { + var self = this; + self.send({ msg: "disconnected", reason: reason }); + }, + // Process 'msg' as an incoming message. (But as a guard against // race conditions during reconnection, ignore the message if // 'socket' is not the currently connected socket.) @@ -599,8 +605,8 @@ _.extend(Session.prototype, { }; // Closes all sessions associated with these tokens except this one. - var closeAll = function (tokens) { - self._closeAllForTokens(tokens, [self.id]); + var closeAll = function (tokens, reason) { + self._closeAllForTokens(tokens, reason); }; var invocation = new MethodInvocation({ @@ -670,9 +676,9 @@ _.extend(Session.prototype, { self.server._loginTokenChanged(self, newToken, oldToken); }, - _closeAllForTokens: function (tokens) { + _closeAllForTokens: function (tokens, reason) { var self = this; - self.server._closeAllForTokens(tokens, [self.id]); + self.server._closeAllForTokens(tokens, reason, [self.id]); }, // Sets the current user id in all appropriate contexts and reruns @@ -1364,21 +1370,37 @@ _.extend(Server.prototype, { self.sessionsByLoginToken[newToken].push(session.id); }, - // Close all open sessions associated with any of the tokens in `tokens`, - // except for sessions with ids in `excludeSessions`. - _closeAllForTokens: function (tokens, excludeSessions) { + // Close all open sessions associated with any of the tokens in `tokens`. If + // `reason` is provided, sends each session a disconnected message before + // closing it. `excludeSessions` is an optional array of strings (session ids) + // to not close, even if they match a token in `tokens`. + _closeAllForTokens: function (tokens, reason, excludeSessions) { var self = this; + + if (! excludeSessions && typeof reason === "object") { + excludeSessions = reason; + reason = undefined; + } + + if (tokens.length) _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { var destroyedIds = []; _.each(self.sessionsByLoginToken[token], function (sessionId) { - if (_.indexOf(excludeSessions, sessionId) === -1) { - self.sessions[sessionId].cleanup(); - self.sessions[sessionId].destroy(); - delete self.sessions[sessionId]; - destroyedIds.push(sessionId); - } + if (_.indexOf(excludeSessions, sessionId) !== -1) + return; + + // Destroy session and remove from self.sessions. + var session = self.sessions[sessionId]; + if (reason) + session.sendDisconnected(reason); + session.cleanup(); + session.destroy(); + delete self.sessions[sessionId]; + destroyedIds.push(sessionId); }); + + // Remove destroyed sessions from self.sessionsByLoginToken. self.sessionsByLoginToken[token] = _.filter( self.sessionsByLoginToken[token], function (sessionId) { From e84f0adb7886d45d68cb4f195d967d4596ffdc75 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 30 Aug 2013 18:09:02 -0700 Subject: [PATCH 034/335] Add test for Meteor._logoutAllOthers. Fix bugs to make it work. --- packages/accounts-base/accounts_client.js | 30 +++++++---- packages/accounts-base/accounts_server.js | 12 +++-- packages/accounts-base/localstorage_token.js | 2 +- packages/accounts-password/password_tests.js | 52 ++++++++++++++++++-- 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 3c48a6c9a4..1b87252ccc 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -180,16 +180,26 @@ Meteor.logout = function (callback) { }); }; -Meteor._logoutAllOthers = function (callback) { - Meteor.apply('_logoutAllOthers', [], {wait: true}, function (error, result) { - if (error) { - callback && callback(error); - } else { - // The method should return a new valid token that we should start using. - makeClientLoggedIn(Meteor.userId(), result.token, result.tokenExpires); - callback && callback(); - } - }); +// Set opts._noDelay to close other open connections without any delay, rather +// than the 10 second default delay. Used by test. +Meteor._logoutAllOthers = function (opts, callback) { + if (! callback && typeof opts === "Function") { + callback = opts; + opts = {}; + } + Meteor.apply('_logoutAllOthers', [opts], { wait: true }, + function (error, result) { + console.log("logged out others"); + if (error) { + callback && callback(error); + } else { + // The method should return a new valid token that we should + // start using. + makeClientLoggedIn(Meteor.userId(), result.token, + result.tokenExpires); + callback && callback(); + } + }); }; /// diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 5d47e83cda..7f07d4bc84 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -95,8 +95,9 @@ Meteor.methods({ // Nuke everything: delete all the user's tokens and close all open // connections logged in as this user, except this connection. Returns a fresh // new login token that this client can use. - _logoutAllOthers: function () { + _logoutAllOthers: function (opts) { var self = this; + opts = opts || {}; var user = Meteor.users.findOne(self.userId); if (user) { var tokens = user.services.resume.loginTokens; @@ -106,11 +107,12 @@ Meteor.methods({ // Wait 10 seconds before closing the connections. This is to give other // clients using our token a chance to find a new token in localStorage so // that when they get disconnected they reconnect with a valid token. + var delay = opts._noDelay ? 0 : 10; Meteor.setTimeout(function () { self._closeAllForTokens(_.map(tokens, function (token) { return token.token; - })); - }, 10*1000); + }), "logged_out"); + }, delay * 1000); var newToken = Accounts._generateStampedLoginToken(); Meteor.users.update(self.userId, { @@ -118,7 +120,7 @@ Meteor.methods({ "services.resume.loginTokens": newToken } }); - self._setLoginToken(newToken); + self._setLoginToken(newToken.token); return { token: newToken.token, tokenExpires: Accounts._tokenExpiration(newToken.when) @@ -202,7 +204,7 @@ var expireTokens = function () { } }, { multi: true }); - Meteor.server._closeAllForTokens(oldTokens); + Meteor.server._closeAllForTokens(oldTokens, "token_expired"); }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 023913cbfa..8e1fb21086 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -73,7 +73,7 @@ var storedUserId = function() { var unstoreLoginTokenIfExpiresSoon = function () { var tokenExpires = Meteor._localStorage.getItem(loginTokenExpiresKey); - if (tokenExpires && Accounts._tokenExpiresSoon(tokenExpires)) + if (tokenExpires && Accounts._tokenExpiresSoon(new Date(tokenExpires))) unstoreLoginToken(); }; diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 0195ee1b18..3ae24348b5 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -277,24 +277,26 @@ if (Meteor.isClient) (function () { }, logoutStep, function(test, expect) { + // XXX would be nice to write this test in a way that avoids the + // possibly-flaky timing stuff (e.g. have a test hook for expiring tokens + // on demand rather than waiting for them to expire) + // Test that login tokens get expired. We should get logged out when a // token expires, and not be able to log in again with the same token. var expectLoggedOut = expect(function () { - test.equal(Meteor.user(), undefined); + test.equal(Meteor.user(), null); }); var expectLoginError = expect(function (err) { test.isTrue(err); }); - var expectNoError = expect(function (err) { - test.equal(err, undefined); - }); var expectToken = expect(function (token) { test.isTrue(token); }); var token; Meteor.loginWithPassword(username, password2, function (error) { - expectNoError(error); + test.isFalse(error); token = Accounts._storedLoginToken(); + expectToken(token); }); Meteor.setTimeout(function () { expectToken(token); @@ -302,6 +304,46 @@ if (Meteor.isClient) (function () { Meteor.loginWithToken(token, expectLoginError); }, 10*1000); }, + logoutStep, + function (test, expect) { + // Test that Meteor._logoutAllOthers logs out a second authenticated + // connection. + var expectLoggedIn = expect(function (err, result) { + test.isTrue(Meteor.user()); + }); + var expectLoginErr = expect(function (err) { + test.isTrue(err); + }); + + var token; + + // copied from livedata/client_convenience.js + var ddpUrl = '/'; + if (typeof __meteor_runtime_config__ !== "undefined") { + if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) + ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; + } + var secondConn = DDP.connect(ddpUrl); + + Meteor.loginWithPassword(username, password2, function (err, result) { + expectLoggedIn(err, result); + token = Accounts._storedLoginToken(); + secondConn.call("login", { + resume: token + }, function (err, result) { + test.isFalse(err); + Meteor._logoutAllOthers({ _noDelay: true }, function () { + // secondConn should be logged out and subsequently fail resume + // login, but Meteor.connection should stay logged in. + Meteor.setTimeout(function () { + test.isTrue(Meteor.user()); + test.isFalse(secondConn.userId()); + secondConn.call("login", { resume: token }, expectLoginErr); + }, 50); + }); + }); + }); + }, logoutStep ]); From 21dd57c95dfd61eb9e2539ebdae08e86f393012f Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Mon, 2 Sep 2013 19:04:01 -0700 Subject: [PATCH 035/335] Allow DDP client to register onDisconnect callback. accounts-base uses this to handle disconnects due to users being intentionally logged out. --- packages/accounts-base/accounts_client.js | 9 ++++++++- packages/livedata/livedata_connection.js | 5 +---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index 1b87252ccc..e60c417bf4 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -112,6 +112,13 @@ Accounts.callLoginMethod = function (options) { }}); } }; + + Meteor.connection._onDisconnect = function (reason) { + var loggedOutReasons = ["logged_out", "token_expired", + "user_deleted", "token_deleted"]; + if (_.indexOf(loggedOutReasons, reason) !== -1) + makeClientLoggedOut(); + }; } }; @@ -162,6 +169,7 @@ makeClientLoggedOut = function() { unstoreLoginToken(); Meteor.connection.setUserId(null); Meteor.connection.onReconnect = null; + Meteor.connection._onDisconnect = null; }; makeClientLoggedIn = function(userId, token, tokenExpires) { @@ -189,7 +197,6 @@ Meteor._logoutAllOthers = function (opts, callback) { } Meteor.apply('_logoutAllOthers', [opts], { wait: true }, function (error, result) { - console.log("logged out others"); if (error) { callback && callback(error); } else { diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index 68ea20ced0..aac5b8e3d9 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -1324,10 +1324,7 @@ _.extend(Connection.prototype, { _livedata_disconnected: function (msg) { var self = this; var reason = msg.reason; - if (reason === "logged_out" || reason === "token_expired") { - self.setUserId(null); - self.onReconnect = null; - } + self._onDisconnect && self._onDisconnect(reason); }, _livedata_error: function (msg) { From 09ba59c50b49e97f7d2d03097ec1277ba3b6d7a9 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Mon, 2 Sep 2013 19:05:00 -0700 Subject: [PATCH 036/335] Close users' connections when they or their tokens are deleted. --- packages/accounts-base/accounts_server.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 7f07d4bc84..203c0f6373 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -525,3 +525,24 @@ Meteor.users._ensureIndex('username', {unique: 1, sparse: 1}); Meteor.users._ensureIndex('emails.address', {unique: 1, sparse: 1}); Meteor.users._ensureIndex('services.resume.loginTokens.token', {unique: 1, sparse: 1}); + +/// +/// LOGGING OUT DELETED USERS +/// + +var closeTokensForUser = function (userTokens, reason) { + Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { + return token.token; + }), reason); +}; + +Meteor.users.find().observe({ + changed: function (newUser, oldUser) { + var removedTokens = _.difference(oldUser.services.resume.loginTokens, + newUser.services.resume.loginTokens); + closeTokensForUser(removedTokens, "token_deleted"); + }, + removed: function (oldUser) { + closeTokensForUser(oldUser.services.resume.loginTokens, "user_deleted"); + } +}); From dab6df0eb919d279617f047787e90ed729845a84 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 3 Sep 2013 20:41:55 -0700 Subject: [PATCH 037/335] Remove DDP disconnected message. Instead, using a friendlier error message for bad login tokens, and preferring localStorage tokens to the result of login method in onReconnect (since onReconnect doesn't get cleared because we don't have a disconnected message to know when to clean it). Also tried to make tests a little cleaner. --- packages/accounts-base/accounts_client.js | 56 ++++++----- packages/accounts-base/accounts_common.js | 2 +- packages/accounts-base/accounts_server.js | 75 ++++++++------ packages/accounts-base/localstorage_token.js | 6 +- packages/accounts-password/password_tests.js | 100 ++++++++++++------- packages/livedata/DDP.md | 12 --- packages/livedata/livedata_common.js | 3 - packages/livedata/livedata_connection.js | 6 -- packages/livedata/livedata_server.js | 39 ++------ 9 files changed, 149 insertions(+), 150 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index e60c417bf4..f61cf24e5e 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -91,13 +91,21 @@ Accounts.callLoginMethod = function (options) { if (err || !result || !result.token) { Meteor.connection.onReconnect = null; } else { - Meteor.connection.onReconnect = function() { + Meteor.connection.onReconnect = function () { reconnected = true; - // XXX A DDP disconnect message would be helpful here, to know if our - // connection got closed because of an expired token. + // If our token was updated in storage, use the latest one. + var storedToken = storedLoginToken(); + if (storedToken) { + result = { + token: storedToken, + tokenExpires: storedLoginTokenExpires() + }; + } if (! result.tokenExpires) result.tokenExpires = Accounts._tokenExpiration(new Date()); - if (! Accounts._tokenExpiresSoon(result.tokenExpires)) { + if (Accounts._tokenExpiresSoon(result.tokenExpires)) { + makeClientLoggedOut(); + } else { Accounts.callLoginMethod({ methodArguments: [{resume: result.token}], // Reconnect quiescence ensures that the user doesn't see an @@ -112,13 +120,6 @@ Accounts.callLoginMethod = function (options) { }}); } }; - - Meteor.connection._onDisconnect = function (reason) { - var loggedOutReasons = ["logged_out", "token_expired", - "user_deleted", "token_deleted"]; - if (_.indexOf(loggedOutReasons, reason) !== -1) - makeClientLoggedOut(); - }; } }; @@ -169,7 +170,6 @@ makeClientLoggedOut = function() { unstoreLoginToken(); Meteor.connection.setUserId(null); Meteor.connection.onReconnect = null; - Meteor.connection._onDisconnect = null; }; makeClientLoggedIn = function(userId, token, tokenExpires) { @@ -188,24 +188,26 @@ Meteor.logout = function (callback) { }); }; -// Set opts._noDelay to close other open connections without any delay, rather -// than the 10 second default delay. Used by test. -Meteor._logoutAllOthers = function (opts, callback) { - if (! callback && typeof opts === "Function") { - callback = opts; - opts = {}; - } - Meteor.apply('_logoutAllOthers', [opts], { wait: true }, +Meteor._logoutAllOthers = function (callback) { + // Our connection is going to be closed, but we don't want to call the + // onReconnect handler until the result comes back for this method, because + // the token will have been deleted on the server. Instead, wait until we get + // a new token and call the reconnect handler with that. + // XXX this is messy. + // XXX what if login gets called before the callback runs? + var origOnReconnect = Meteor.connection.onReconnect; + var userId = Meteor.userId(); + Meteor.connection.onReconnect = null; + Meteor.apply('_logoutAllOthers', [], { wait: true }, function (error, result) { if (error) { - callback && callback(error); - } else { - // The method should return a new valid token that we should - // start using. - makeClientLoggedIn(Meteor.userId(), result.token, - result.tokenExpires); - callback && callback(); + if (! Meteor.user()) + makeClientLoggedOut(); } + Meteor.connection.onReconnect = origOnReconnect; + storeLoginToken(userId, result.token, result.tokenExpires); + Meteor.connection.onReconnect(); + callback && callback(error); }); }; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index ffbd10ad57..4b2cc95d70 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -31,7 +31,7 @@ Accounts.config = function(options) { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", "_tokenLifetime", "_tokenExpirationInterval", - "_minTokenLifetime"]; + "_minTokenLifetime", "_connectionCloseDelay"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 203c0f6373..4374ad2589 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -95,36 +95,27 @@ Meteor.methods({ // Nuke everything: delete all the user's tokens and close all open // connections logged in as this user, except this connection. Returns a fresh // new login token that this client can use. - _logoutAllOthers: function (opts) { + _logoutAllOthers: function () { var self = this; - opts = opts || {}; var user = Meteor.users.findOne(self.userId); if (user) { var tokens = user.services.resume.loginTokens; - Meteor.users.update(self.userId, { - $set: { "services.resume.loginTokens": [] } - }); - // Wait 10 seconds before closing the connections. This is to give other - // clients using our token a chance to find a new token in localStorage so - // that when they get disconnected they reconnect with a valid token. - var delay = opts._noDelay ? 0 : 10; - Meteor.setTimeout(function () { - self._closeAllForTokens(_.map(tokens, function (token) { - return token.token; - }), "logged_out"); - }, delay * 1000); - var newToken = Accounts._generateStampedLoginToken(); Meteor.users.update(self.userId, { - $push: { - "services.resume.loginTokens": newToken + $set: { + "services.resume.loginTokens": [newToken] } }); - self._setLoginToken(newToken.token); + // We do not set the login token on this connection, to force the client + // to close this connection and open a new one with the new token. + // The observe on Meteor.users() will take care of closing connections + // with the right delay. return { token: newToken.token, tokenExpires: Accounts._tokenExpiration(newToken.when) }; + } else { + throw new Error("You are not logged in."); } } }); @@ -147,8 +138,10 @@ Accounts.registerLoginHandler(function(options) { "services.resume.loginTokens.token": ""+options.resume }); - if (!user) - throw new Meteor.Error(403, "Couldn't find login token"); + if (!user) { + throw new Meteor.Error(403, "You've been logged out by the server. " + + "Please login again."); + } var token = _.find(user.services.resume.loginTokens, function (token) { return token.token === options.resume; @@ -176,10 +169,11 @@ var removeLoginToken = function (userId, loginToken) { }; // Deletes expired tokens from the database and closes all open connections -// associated with these tokens. -var expireTokens = function () { +// associated with these tokens. Exported for tests. +var expireTokens = Accounts._expireTokens = function (oldestValidDate) { var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; - var oldestValidDate = new Date(new Date() - tokenLifetime * 1000); + oldestValidDate = oldestValidDate || + (new Date(new Date() - tokenLifetime * 1000)); var usersWithExpiredTokens = Meteor.users.find({ "services.resume.loginTokens.when": { $lt: oldestValidDate } }); @@ -203,8 +197,8 @@ var expireTokens = function () { } } }, { multi: true }); - - Meteor.server._closeAllForTokens(oldTokens, "token_expired"); + // The observe on Meteor.users will take care of closing connections for + // expired tokens. }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); @@ -530,19 +524,34 @@ Meteor.users._ensureIndex('services.resume.loginTokens.token', /// LOGGING OUT DELETED USERS /// -var closeTokensForUser = function (userTokens, reason) { - Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { - return token.token; - }), reason); +// By default, connections are closed with a 10 second delay, to give other +// clients a chance to find a new token in localStorage before +// reconnecting. Delay can be configured with Accounts.config. +var closeTokensForUser = function (userTokens) { + var delay = 10; + if (_.has(Accounts._options, "_connectionCloseDelay")) + delay = Accounts._options._connectionCloseDelay; + Meteor.setTimeout(function () { + Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { + return token.token; + })); + }, delay * 1000); }; Meteor.users.find().observe({ changed: function (newUser, oldUser) { - var removedTokens = _.difference(oldUser.services.resume.loginTokens, - newUser.services.resume.loginTokens); - closeTokensForUser(removedTokens, "token_deleted"); + var removedTokens = []; + if (newUser.services && newUser.services.resume && + oldUser.services && oldUser.services.resume) { + removedTokens = _.difference(oldUser.services.resume.loginTokens || [], + newUser.services.resume.loginTokens || []); + } else if (oldUser.services && oldUser.services.resume) { + removedTokens = oldUser.services.resume.loginTokens || []; + } + closeTokensForUser(removedTokens); }, removed: function (oldUser) { - closeTokensForUser(oldUser.services.resume.loginTokens, "user_deleted"); + if (oldUser.services && oldUser.services.resume) + closeTokensForUser(oldUser.services.resume.loginTokens || []); } }); diff --git a/packages/accounts-base/localstorage_token.js b/packages/accounts-base/localstorage_token.js index 8e1fb21086..24c6e25318 100644 --- a/packages/accounts-base/localstorage_token.js +++ b/packages/accounts-base/localstorage_token.js @@ -63,10 +63,14 @@ unstoreLoginToken = function() { // This is private, but it is exported for now because it is used by a // test in accounts-password. // -var storedLoginToken = Accounts._storedLoginToken = function() { +storedLoginToken = Accounts._storedLoginToken = function() { return Meteor._localStorage.getItem(loginTokenKey); }; +storedLoginTokenExpires = function () { + return Meteor._localStorage.getItem(loginTokenExpiresKey); +}; + var storedUserId = function() { return Meteor._localStorage.getItem(userIdKey); }; diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 3ae24348b5..91d9141d36 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1,7 +1,5 @@ Accounts.config({ - _tokenLifetime: 5, - _tokenExpirationInterval: 5, - _minTokenLifetime: 1 + _connectionCloseDelay: 0 }); if (Meteor.isClient) (function () { @@ -277,10 +275,6 @@ if (Meteor.isClient) (function () { }, logoutStep, function(test, expect) { - // XXX would be nice to write this test in a way that avoids the - // possibly-flaky timing stuff (e.g. have a test hook for expiring tokens - // on demand rather than waiting for them to expire) - // Test that login tokens get expired. We should get logged out when a // token expires, and not be able to log in again with the same token. var expectLoggedOut = expect(function () { @@ -289,29 +283,43 @@ if (Meteor.isClient) (function () { var expectLoginError = expect(function (err) { test.isTrue(err); }); - var expectToken = expect(function (token) { - test.isTrue(token); + var expectNoError = expect(function (err) { + test.isFalse(err); }); var token; + var firstLoginCallback = true; Meteor.loginWithPassword(username, password2, function (error) { - test.isFalse(error); - token = Accounts._storedLoginToken(); - expectToken(token); + // callback will be called again on reconnect after our token gets + // expired. + if (firstLoginCallback) { + token = Accounts._storedLoginToken(); + test.isTrue(token); + test.isFalse(error); + Meteor.call("expireTokens", new Date(), function (error, result) { + expectNoError(error); + }); + } else { + expectLoggedOut(); + Meteor.loginWithToken(token, function (err) { + test.isFalse(Meteor.userId()); + expectLoginError(err); + }); + } + firstLoginCallback = false; }); - Meteor.setTimeout(function () { - expectToken(token); - expectLoggedOut(); - Meteor.loginWithToken(token, expectLoginError); - }, 10*1000); }, logoutStep, function (test, expect) { // Test that Meteor._logoutAllOthers logs out a second authenticated // connection. - var expectLoggedIn = expect(function (err, result) { - test.isTrue(Meteor.user()); + + var expectNoError = expect(function (err) { + test.isFalse(err); }); - var expectLoginErr = expect(function (err) { + var expectSecondConnLoggedOut = expect(function () { + test.isFalse(secondConn.userId()); + }); + var expectLoginError = expect(function (err) { test.isTrue(err); }); @@ -325,23 +333,37 @@ if (Meteor.isClient) (function () { } var secondConn = DDP.connect(ddpUrl); - Meteor.loginWithPassword(username, password2, function (err, result) { - expectLoggedIn(err, result); - token = Accounts._storedLoginToken(); - secondConn.call("login", { - resume: token - }, function (err, result) { - test.isFalse(err); - Meteor._logoutAllOthers({ _noDelay: true }, function () { - // secondConn should be logged out and subsequently fail resume - // login, but Meteor.connection should stay logged in. - Meteor.setTimeout(function () { - test.isTrue(Meteor.user()); - test.isFalse(secondConn.userId()); - secondConn.call("login", { resume: token }, expectLoginErr); - }, 50); - }); + var firstLoginCallback = true; + + secondConn.onReconnect = function () { + expectSecondConnLoggedOut(); + secondConn.call("login", { resume: token }, function (err, result) { + test.isFalse(secondConn.userId()); + expectLoginError(err); }); + }; + + Meteor.loginWithPassword(username, password2, function (err, result) { + test.isFalse(err); + if (firstLoginCallback) { + test.isTrue(Meteor.user()); + token = Accounts._storedLoginToken(); + secondConn.call("login", { + resume: token + }, function (err, result) { + test.isFalse(err); + Meteor._logoutAllOthers(function (err) { + expectNoError(err); + }); + }); + } else { + // Callback fires again after reconnect. We should still be logged in, + // but secondConn should be logged out and subsequently fail resume + // login. + test.isFalse(err); + test.isTrue(Meteor.user()); + } + firstLoginCallback = false; }); }, logoutStep @@ -352,6 +374,12 @@ if (Meteor.isClient) (function () { if (Meteor.isServer) (function () { + Meteor.methods({ + expireTokens: function (oldestValidDate) { + Accounts._expireTokens(oldestValidDate); + } + }); + Tinytest.add( 'passwords - setup more than one onCreateUserHook', function (test) { diff --git a/packages/livedata/DDP.md b/packages/livedata/DDP.md index 2ef93578e3..dc59ece7c9 100644 --- a/packages/livedata/DDP.md +++ b/packages/livedata/DDP.md @@ -222,18 +222,6 @@ The error message contains the following fields: * `offendingMessage`: if the original message parsed properly, it is included here -## Disconnection - -### Messages: - - * `disconnected` (server->client) - - `reason`: string ("logged_out" or "token_expired") - -### Procedure: - - * Before the server disconnects a client, it can optionally send a disconnected - message to indicate the cause of the disconnect. - ## Appendix: EJSON EJSON is a way of embedding more than the built-in JSON types in JSON. It diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index dc2510c353..8c45863c85 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -33,9 +33,6 @@ MethodInvocation = function (options) { // deleted, etc. this._setLoginToken = options._setLoginToken || function () {}; - // Closes all open connections associated with the given tokens. - this._closeAllForTokens = options._closeAllForTokens || function () {}; - // Scratch data scoped to this connection (livedata_connection on the // client, livedata_session on the server). This is only used // internally, but we should have real and documented API for this diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index aac5b8e3d9..ef70ecb6ea 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -1321,12 +1321,6 @@ _.extend(Connection.prototype, { }); }, - _livedata_disconnected: function (msg) { - var self = this; - var reason = msg.reason; - self._onDisconnect && self._onDisconnect(reason); - }, - _livedata_error: function (msg) { Meteor._debug("Received error from server: ", msg.reason); if (msg.offendingMessage) diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index c4bf1b06cf..aeee0f0204 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -452,12 +452,6 @@ _.extend(Session.prototype, { self.send(msg); }, - // Send a DDP disconnected message. - sendDisconnected: function (reason) { - var self = this; - self.send({ msg: "disconnected", reason: reason }); - }, - // Process 'msg' as an incoming message. (But as a guard against // race conditions during reconnection, ignore the message if // 'socket' is not the currently connected socket.) @@ -604,17 +598,11 @@ _.extend(Session.prototype, { self._setLoginToken(newToken); }; - // Closes all sessions associated with these tokens except this one. - var closeAll = function (tokens, reason) { - self._closeAllForTokens(tokens, reason); - }; - var invocation = new MethodInvocation({ isSimulation: false, userId: self.userId, setUserId: setUserId, _setLoginToken: setLoginToken, - _closeAllForTokens: closeAll, unblock: unblock, sessionData: self.sessionData }); @@ -676,11 +664,6 @@ _.extend(Session.prototype, { self.server._loginTokenChanged(self, newToken, oldToken); }, - _closeAllForTokens: function (tokens, reason) { - var self = this; - self.server._closeAllForTokens(tokens, reason, [self.id]); - }, - // Sets the current user id in all appropriate contexts and reruns // all subscriptions _setUserId: function(userId) { @@ -1370,18 +1353,12 @@ _.extend(Server.prototype, { self.sessionsByLoginToken[newToken].push(session.id); }, - // Close all open sessions associated with any of the tokens in `tokens`. If - // `reason` is provided, sends each session a disconnected message before - // closing it. `excludeSessions` is an optional array of strings (session ids) + // Close all open sessions associated with any of the tokens in + // `tokens`. `excludeSessions` is an optional array of strings (session ids) // to not close, even if they match a token in `tokens`. - _closeAllForTokens: function (tokens, reason, excludeSessions) { + _closeAllForTokens: function (tokens, excludeSessions) { var self = this; - if (! excludeSessions && typeof reason === "object") { - excludeSessions = reason; - reason = undefined; - } - if (tokens.length) _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { @@ -1392,11 +1369,11 @@ _.extend(Server.prototype, { // Destroy session and remove from self.sessions. var session = self.sessions[sessionId]; - if (reason) - session.sendDisconnected(reason); - session.cleanup(); - session.destroy(); - delete self.sessions[sessionId]; + if (session) { + session.cleanup(); + session.destroy(); + delete self.sessions[sessionId]; + } destroyedIds.push(sessionId); }); From 4ebdbd9f77f5870441a72bb7c2c0b57f8897712c Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 4 Sep 2013 10:43:47 -0700 Subject: [PATCH 038/335] On logout, disassociate connection from token before deleting token. --- packages/accounts-base/accounts_server.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 4374ad2589..66ba568f0b 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -85,11 +85,10 @@ Meteor.methods({ logout: function() { var token = this._getLoginToken(); + this._setLoginToken(null); if (token && this.userId) removeLoginToken(this.userId, token); this.setUserId(null); - this._setLoginToken(null); - // XXX should close all connections open with this token? }, // Nuke everything: delete all the user's tokens and close all open @@ -160,6 +159,8 @@ Accounts._generateStampedLoginToken = function () { return {token: Random.id(), when: (new Date)}; }; +// Deletes the given loginToken from the database. This will cause all +// connections associated with the token to be closed. var removeLoginToken = function (userId, loginToken) { Meteor.users.update(userId, { $pull: { From d9093d6cf8933f26c47260968cb2846af78fe646 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 4 Sep 2013 10:53:46 -0700 Subject: [PATCH 039/335] Test for logging out a user's connections when deleted. Tests are flaky, not sure why yet. --- packages/accounts-password/password_tests.js | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 91d9141d36..56f1650bb1 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -366,7 +366,23 @@ if (Meteor.isClient) (function () { firstLoginCallback = false; }); }, - logoutStep + logoutStep, + function (test, expect) { + // Test that deleting a user logs out that user's connections. + var expectLoginError = expect(function (err) { + test.isTrue(err); + }); + var firstLoginCallback = true; + Meteor.loginWithPassword(username, password2, function (err) { + if (firstLoginCallback) { + test.isFalse(err); + Meteor.call("removeUser", username); + } else { + expectLoginError(err); + } + firstLoginCallback = false; + }); + } ]); }) (); @@ -377,6 +393,9 @@ if (Meteor.isServer) (function () { Meteor.methods({ expireTokens: function (oldestValidDate) { Accounts._expireTokens(oldestValidDate); + }, + removeUser: function (username) { + Meteor.users.remove({ "username": username }); } }); From 0bc4bc9c26299085b0b5521dd1a1b7b1d67cad51 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 5 Sep 2013 17:11:27 -0700 Subject: [PATCH 040/335] Clean up token deletion and tests. --- packages/accounts-base/accounts_client.js | 7 +- packages/accounts-base/accounts_common.js | 30 +++--- packages/accounts-base/accounts_server.js | 69 +++++++------- packages/accounts-password/password_tests.js | 97 ++++++++------------ packages/livedata/livedata_common.js | 4 +- packages/livedata/livedata_connection.js | 2 - packages/livedata/livedata_server.js | 11 +-- 7 files changed, 98 insertions(+), 122 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index f61cf24e5e..9aef4bdc97 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -200,12 +200,9 @@ Meteor._logoutAllOthers = function (callback) { Meteor.connection.onReconnect = null; Meteor.apply('_logoutAllOthers', [], { wait: true }, function (error, result) { - if (error) { - if (! Meteor.user()) - makeClientLoggedOut(); - } Meteor.connection.onReconnect = origOnReconnect; - storeLoginToken(userId, result.token, result.tokenExpires); + if (! error) + storeLoginToken(userId, result.token, result.tokenExpires); Meteor.connection.onReconnect(); callback && callback(error); }); diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 4b2cc95d70..1de58f1b2d 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -19,19 +19,23 @@ Accounts._options = {}; // client signups. // - forbidClientAccountCreation {Boolean} // Do not allow clients to create accounts directly. -// - _tokenLifetime {Number} +// - _tokenLifetimeSecs {Number} // Seconds until a login token expires. -// - _tokenExpirationInterval {Number} +// - _tokenExpirationIntervalSecs {Number} // How often (in seconds) to check for expired tokens -// - _minTokenLifetime {Number} +// - _minTokenLifetimeSecs {Number} // The minimum number of seconds until a token expires in order for the // client to be willing to connect with that token. +// - _connectionCloseDelaySecs {Number} +// The number of seconds to wait before closing connections that when a user +// is logged out by the server. Defaults to 10, to allow clients to store a +// fresh token in localStorage when calling _logoutAllOthers. // Accounts.config = function(options) { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", - "_tokenLifetime", "_tokenExpirationInterval", - "_minTokenLifetime", "_connectionCloseDelay"]; + "_tokenLifetimeSecs", "_tokenExpirationIntervalSecs", + "_minTokenLifetimeSecs", "_connectionCloseDelaySecs"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); @@ -78,19 +82,19 @@ Accounts.LoginCancelledError.prototype = new Error(); Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError'; // how long (in seconds) until a login token expires -DEFAULT_TOKEN_LIFETIME = 604800; // one week +DEFAULT_TOKEN_LIFETIME_SECS = 604800; // one week // We don't try to auto-login with a token that is going to expire within // MIN_TOKEN_LIFETIME seconds, to avoid abrupt disconnects from expiring tokens. -var DEFAULT_MIN_TOKEN_LIFETIME = 3600; // one hour +var DEFAULT_MIN_TOKEN_LIFETIME_SECS = 3600; // one hour Accounts._tokenExpiration = function (when) { - var tokenLifetime = Accounts._options._tokenLifetime || - DEFAULT_TOKEN_LIFETIME; - return new Date(when.getTime() + tokenLifetime * 1000); + var tokenLifetimeSecs = Accounts._options._tokenLifetimeSecs || + DEFAULT_TOKEN_LIFETIME_SECS; + return new Date(when.getTime() + tokenLifetimeSecs * 1000); }; Accounts._tokenExpiresSoon = function (when) { - var minLifetime = Accounts._options._minTokenLifetime || - DEFAULT_MIN_TOKEN_LIFETIME; - return new Date() > (new Date(when) - minLifetime * 1000); + var minLifetimeSecs = Accounts._options._minTokenLifetimeSecs || + DEFAULT_MIN_TOKEN_LIFETIME_SECS; + return new Date() > (new Date(when) - minLifetimeSecs * 1000); }; diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 66ba568f0b..c97f0e17ab 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -91,12 +91,16 @@ Meteor.methods({ this.setUserId(null); }, - // Nuke everything: delete all the user's tokens and close all open - // connections logged in as this user, except this connection. Returns a fresh - // new login token that this client can use. + // Nuke everything: delete all the current user's tokens and close all open + // connections logged in as this user. Returns a fresh new login token that + // this client can use. _logoutAllOthers: function () { var self = this; - var user = Meteor.users.findOne(self.userId); + var user = Meteor.users.findOne(self.userId, { + fields: { + "services.resume.loginTokens": true + } + }); if (user) { var tokens = user.services.resume.loginTokens; var newToken = Accounts._generateStampedLoginToken(); @@ -105,10 +109,9 @@ Meteor.methods({ "services.resume.loginTokens": [newToken] } }); - // We do not set the login token on this connection, to force the client - // to close this connection and open a new one with the new token. - // The observe on Meteor.users() will take care of closing connections - // with the right delay. + // We do not set the login token on this connection, but instead the + // observe closes the connection and the client will reconnect with the + // new token. return { token: newToken.token, tokenExpires: Accounts._tokenExpiration(newToken.when) @@ -125,7 +128,7 @@ Meteor.methods({ /// support reconnecting using a meteor login token // how often (in seconds) we check for expired tokens -var DEFAULT_EXPIRE_TOKENS_INTERVAL = 600; // 10 minutes +var DEFAULT_EXPIRE_TOKENS_INTERVAL_SECS = 600; // 10 minutes // Login handler for resume tokens. Accounts.registerLoginHandler(function(options) { @@ -146,9 +149,13 @@ Accounts.registerLoginHandler(function(options) { return token.token === options.resume; }); + var tokenExpires = Accounts._tokenExpiration(token.when); + if (new Date() >= tokenExpires) + throw new Meteor.Error(403, "Your session has expired. Please login again."); + return { token: options.resume, - tokenExpires: Accounts._tokenExpiration(token.when), + tokenExpires: tokenExpires, id: user._id }; }); @@ -169,25 +176,19 @@ var removeLoginToken = function (userId, loginToken) { }); }; +/// +/// TOKEN EXPIRATION +/// + +var expireTokenInterval; + // Deletes expired tokens from the database and closes all open connections // associated with these tokens. Exported for tests. var expireTokens = Accounts._expireTokens = function (oldestValidDate) { - var tokenLifetime = Accounts._options._tokenLifetime || DEFAULT_TOKEN_LIFETIME; + var tokenLifetimeSecs = Accounts._options._tokenLifetimeSecs || + DEFAULT_TOKEN_LIFETIME_SECS; oldestValidDate = oldestValidDate || - (new Date(new Date() - tokenLifetime * 1000)); - var usersWithExpiredTokens = Meteor.users.find({ - "services.resume.loginTokens.when": { $lt: oldestValidDate } - }); - - var oldTokens = []; - usersWithExpiredTokens.forEach(function (user) { - _.each(user.services.resume.loginTokens, function (token) { - if (typeof token.when === "number") - token.when = new Date(token.when); - if (token.when < oldestValidDate) - oldTokens.push(token.token); - }); - }); + (new Date(new Date() - tokenLifetimeSecs * 1000)); Meteor.users.update({ "services.resume.loginTokens.when": { $lt: oldestValidDate } @@ -203,13 +204,13 @@ var expireTokens = Accounts._expireTokens = function (oldestValidDate) { }; Meteor.users._ensureIndex("services.resume.loginTokens.when", { sparse: true }); -var expireTokenInterval; + initExpireTokenInterval = function () { if (expireTokenInterval) Meteor.clearInterval(expireTokenInterval); - var expirePeriod = Accounts._options._tokenExpirationInterval || - DEFAULT_EXPIRE_TOKENS_INTERVAL; - expireTokenInterval = Meteor.setInterval(expireTokens, expirePeriod * 1000); + var expirePeriodSecs = Accounts._options._tokenExpirationIntervalSecs || + DEFAULT_EXPIRE_TOKENS_INTERVAL_SECS; + expireTokenInterval = Meteor.setInterval(expireTokens, expirePeriodSecs * 1000); }; initExpireTokenInterval(); @@ -525,18 +526,20 @@ Meteor.users._ensureIndex('services.resume.loginTokens.token', /// LOGGING OUT DELETED USERS /// +var DEFAULT_CONNECTION_CLOSE_DELAY_SECS = 10; + // By default, connections are closed with a 10 second delay, to give other // clients a chance to find a new token in localStorage before // reconnecting. Delay can be configured with Accounts.config. var closeTokensForUser = function (userTokens) { - var delay = 10; - if (_.has(Accounts._options, "_connectionCloseDelay")) - delay = Accounts._options._connectionCloseDelay; + var delaySecs = DEFAULT_CONNECTION_CLOSE_DELAY_SECS; + if (_.has(Accounts._options, "_connectionCloseDelaySecs")) + delaySecs = Accounts._options._connectionCloseDelaySecs; Meteor.setTimeout(function () { Meteor.server._closeAllForTokens(_.map(userTokens, function (token) { return token.token; })); - }, delay * 1000); + }, delaySecs * 1000); }; Meteor.users.find().observe({ diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 56f1650bb1..00b525f966 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1,5 +1,5 @@ Accounts.config({ - _connectionCloseDelay: 0 + _connectionCloseDelaySecs: 0 }); if (Meteor.isClient) (function () { @@ -277,91 +277,68 @@ if (Meteor.isClient) (function () { function(test, expect) { // Test that login tokens get expired. We should get logged out when a // token expires, and not be able to log in again with the same token. - var expectLoggedOut = expect(function () { - test.equal(Meteor.user(), null); + var expectNoError = expect(function (err) { + test.isFalse(err); }); var expectLoginError = expect(function (err) { test.isTrue(err); }); - var expectNoError = expect(function (err) { - test.isFalse(err); - }); - var token; var firstLoginCallback = true; + Meteor.loginWithPassword(username, password2, function (error) { - // callback will be called again on reconnect after our token gets - // expired. + var token = Accounts._storedLoginToken(); if (firstLoginCallback) { - token = Accounts._storedLoginToken(); test.isTrue(token); - test.isFalse(error); - Meteor.call("expireTokens", new Date(), function (error, result) { - expectNoError(error); - }); + expectNoError(error); + Meteor.call("expireTokens", new Date()); } else { - expectLoggedOut(); - Meteor.loginWithToken(token, function (err) { - test.isFalse(Meteor.userId()); - expectLoginError(err); - }); + test.isFalse(token); + expectLoginError(error); } firstLoginCallback = false; }); }, logoutStep, function (test, expect) { - // Test that Meteor._logoutAllOthers logs out a second authenticated - // connection. - - var expectNoError = expect(function (err) { - test.isFalse(err); - }); - var expectSecondConnLoggedOut = expect(function () { - test.isFalse(secondConn.userId()); - }); - var expectLoginError = expect(function (err) { - test.isTrue(err); - }); - - var token; - // copied from livedata/client_convenience.js var ddpUrl = '/'; if (typeof __meteor_runtime_config__ !== "undefined") { if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; } + + // Test that Meteor._logoutAllOthers logs out a second authenticated + // connection while leaving Meteor.connection logged in. + var token; var secondConn = DDP.connect(ddpUrl); - var firstLoginCallback = true; + var userId; - secondConn.onReconnect = function () { - expectSecondConnLoggedOut(); - secondConn.call("login", { resume: token }, function (err, result) { - test.isFalse(secondConn.userId()); - expectLoginError(err); - }); - }; - - Meteor.loginWithPassword(username, password2, function (err, result) { + var expectNoError = expect(function (err) { test.isFalse(err); + }); + var expectLoginError = expect(function (err) { + test.isTrue(err); + }); + var expectLoggedIn = expect(function () { + test.equal(userId, Meteor.userId()); + }); + var expectSecondConnLoggedIn = expect(function (err, result) { + test.equal(result.token, token); + test.isFalse(err); + secondConn.onReconnect = function () { + secondConn.call("login", { resume: token }, expectLoginError); + }; + Meteor.call("_logoutAllOthers", expectLoggedIn); + }); + + Meteor.loginWithPassword(username, password2, function (err) { if (firstLoginCallback) { - test.isTrue(Meteor.user()); + expectNoError(err); token = Accounts._storedLoginToken(); - secondConn.call("login", { - resume: token - }, function (err, result) { - test.isFalse(err); - Meteor._logoutAllOthers(function (err) { - expectNoError(err); - }); - }); - } else { - // Callback fires again after reconnect. We should still be logged in, - // but secondConn should be logged out and subsequently fail resume - // login. - test.isFalse(err); - test.isTrue(Meteor.user()); + test.isTrue(token); + userId = Meteor.userId(); + secondConn.call("login", { resume: token }, expectSecondConnLoggedIn); } firstLoginCallback = false; }); @@ -377,6 +354,8 @@ if (Meteor.isClient) (function () { if (firstLoginCallback) { test.isFalse(err); Meteor.call("removeUser", username); + // When the user is deleted, our connection will be closed, triggering + // a reconnect, which will trigger a login attempt. } else { expectLoginError(err); } diff --git a/packages/livedata/livedata_common.js b/packages/livedata/livedata_common.js index 8c45863c85..5ce89ed443 100644 --- a/packages/livedata/livedata_common.js +++ b/packages/livedata/livedata_common.js @@ -29,8 +29,8 @@ MethodInvocation = function (options) { // reruns subscriptions this._setUserId = options.setUserId || function () {}; - // saves the login token so we can delete it later when the user logs out, is - // deleted, etc. + // used for associating the connection with a login token so that the + // connection can be closed if the token is no longer valid this._setLoginToken = options._setLoginToken || function () {}; // Scratch data scoped to this connection (livedata_connection on the diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index ef70ecb6ea..04fb380f90 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -209,8 +209,6 @@ var Connection = function (url, options) { self._livedata_nosub(msg); else if (msg.msg === 'result') self._livedata_result(msg); - else if (msg.msg === 'disconnected') - self._livedata_disconnected(msg); else if (msg.msg === 'error') self._livedata_error(msg); else diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index aeee0f0204..fb3a28a0ed 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -1294,6 +1294,7 @@ _.extend(Server.prototype, { throw new Error("Can't call setUserId on a server initiated method call"); }; var setLoginToken = function () { + // XXX is this correct? throw new Error("Can't call _setLoginToken on a server " + "initiated method call"); }; @@ -1354,19 +1355,13 @@ _.extend(Server.prototype, { }, // Close all open sessions associated with any of the tokens in - // `tokens`. `excludeSessions` is an optional array of strings (session ids) - // to not close, even if they match a token in `tokens`. - _closeAllForTokens: function (tokens, excludeSessions) { + // `tokens`. + _closeAllForTokens: function (tokens) { var self = this; - - if (tokens.length) _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { var destroyedIds = []; _.each(self.sessionsByLoginToken[token], function (sessionId) { - if (_.indexOf(excludeSessions, sessionId) !== -1) - return; - // Destroy session and remove from self.sessions. var session = self.sessions[sessionId]; if (session) { From 00a70b78998958e84def6fcc3fbbc11ec85225d6 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 11 Sep 2013 13:48:05 -0700 Subject: [PATCH 041/335] Update History.md for token deletion --- History.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/History.md b/History.md index 05ebf690ee..cda692dbd5 100644 --- a/History.md +++ b/History.md @@ -3,6 +3,10 @@ * Write dates to Mongo as ISODate rather than Integer; existing data can be converted by passing it through `new Date()`. #1228 +* Login token deletion: Expire login tokens periodically. Add + Meteor._logoutAllOthers() for logging out other connections logged in as the + current user. Log out and close connections for deleted users and tokens. + ## v0.6.5.1 From 4714f892f56b71af1368faf95ff23f6c33bff729 Mon Sep 17 00:00:00 2001 From: Andrew Wilcox Date: Thu, 5 Sep 2013 17:29:19 -0400 Subject: [PATCH 042/335] Check that the argument to EJSON.parse is a string. Some Android browser versions of JSON.parse can crash when passed null (https://code.google.com/p/android/issues/detail?id=11973), so it's better not to pass on a non-string argument to JSON.parse. Thanks to @raix for raising the issue in #1401. --- packages/ejson/ejson.js | 2 ++ packages/ejson/ejson_test.js | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/ejson/ejson.js b/packages/ejson/ejson.js index 3bce3f802f..222a289e50 100644 --- a/packages/ejson/ejson.js +++ b/packages/ejson/ejson.js @@ -211,6 +211,8 @@ EJSON.stringify = function (item) { }; EJSON.parse = function (item) { + if (typeof item !== 'string') + throw new Error("EJSON.parse argument should be a string"); return EJSON.fromJSONValue(JSON.parse(item)); }; diff --git a/packages/ejson/ejson_test.js b/packages/ejson/ejson_test.js index bd3e5d6310..27c39d6085 100644 --- a/packages/ejson/ejson_test.js +++ b/packages/ejson/ejson_test.js @@ -72,3 +72,11 @@ Tinytest.add("ejson - clone", function (test) { }; testCloneArgs(1, 2, "foo", [4]); }); + +Tinytest.add("ejson - parse", function (test) { + test.equal(EJSON.parse("[1,2,3]"), [1,2,3]); + test.throws( + function () { EJSON.parse(null) }, + /argument should be a string/ + ); +}); From a174b549b95f1712a5f7809c0dd5f524884cf338 Mon Sep 17 00:00:00 2001 From: David Winterbottom Date: Mon, 9 Sep 2013 09:47:44 +0100 Subject: [PATCH 043/335] Correct typo in install script error message --- scripts/admin/install-engine.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/admin/install-engine.sh b/scripts/admin/install-engine.sh index f2a2ccf330..1199b8724a 100644 --- a/scripts/admin/install-engine.sh +++ b/scripts/admin/install-engine.sh @@ -46,7 +46,7 @@ elif [ "$UNAME" = "Linux" ] ; then ### Linux ### ARCH=$(uname -m) if [ "$ARCH" != "i686" -a "$ARCH" != "x86_64" ] ; then - echo "Unable architecture: $ARCH" + echo "Unusable architecture: $ARCH" echo "Meteor only supports i686 and x86_64 for now." exit 1 fi From 3665b31a82f8ded87656d2e79317cf243c40cccb Mon Sep 17 00:00:00 2001 From: David Glasser Date: Thu, 12 Sep 2013 13:55:15 -0700 Subject: [PATCH 044/335] Remove backwards-compatibility implementation of pre-0.6.5 Package.register_extension API. It didn't even actually work for producing JavaScript files (eg a coffeescript-like package): see #1410. Package maintainers should have upgraded to the more powerful 0.6.5 API by now anyway. --- History.md | 4 ++ docs/client/concepts.html | 3 +- tools/packages.js | 138 +++----------------------------------- 3 files changed, 13 insertions(+), 132 deletions(-) diff --git a/History.md b/History.md index cda692dbd5..5e6cd7fd65 100644 --- a/History.md +++ b/History.md @@ -7,6 +7,10 @@ Meteor._logoutAllOthers() for logging out other connections logged in as the current user. Log out and close connections for deleted users and tokens. +* The pre-0.6.5 `Package.register_extension` API has been removed. Use + `Package._transitional_registerBuildPlugin` instead, which was introduced in + 0.6.5. (A bug prevented the 0.6.5 reimplementation of `register_extension` + from working properly anyway.) ## v0.6.5.1 diff --git a/docs/client/concepts.html b/docs/client/concepts.html index f0c59d774f..61d47d7c18 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -913,8 +913,7 @@ quick tips: an example. Build plugins are fully-fledged Meteor programs in their own right and have their own namespace, package dependencies, source files and npm requirements. The old `register_extension` API is - deprecated and should not be used as it will prevent your package - from being cached, slowing down builds. + removed. * It is possible to create weak dependencies between packages. If package A has a weak dependency on package B, it means that diff --git a/tools/packages.js b/tools/packages.js index 2fc9b20975..09290ac63d 100644 --- a/tools/packages.js +++ b/tools/packages.js @@ -695,10 +695,7 @@ _.extend(Slice.prototype, { }); _.each(self._activePluginPackages(), function (otherPkg) { - var all = _.extend({}, otherPkg.sourceHandlers); - _.extend(all, otherPkg.legacyExtensionHandlers); - - _.each(all, function (handler, ext) { + _.each(otherPkg.sourceHandlers, function (handler, ext) { if (ext in ret && ret[ext] !== handler) { buildmessage.error( "conflict: two packages included in " + @@ -794,10 +791,6 @@ var Package = function (library, packageDirectoryForBuildInfo) { // Package metadata. Keys are 'summary' and 'internal'. self.metadata = {}; - // File handler extensions defined by this package. Map from file - // extension to the handler function. - self.legacyExtensionHandlers = {}; - // Available editions/subpackages ("slices") of this package. Array // of Slice. self.slices = []; @@ -1175,125 +1168,12 @@ _.extend(Package.prototype, { // XXX COMPAT WITH 0.6.4 // extension doesn't contain a dot - register_extension: function (extension, callback) { - if (_.has(self.legacyExtensionHandlers, extension)) { - buildmessage.error("duplicate handler for '*." + extension + - "'; only one per package allowed", - { useMyCaller: true }); - // Recover by ignoring the duplicate - return; - } - self.legacyExtensionHandlers[extension] = function (compileStep) { - - // In the old extension API, there is a 'where' parameter - // that conflates architecture and slice name and can be - // either "client" or "server". - var clientOrServer = archinfo.matches(compileStep.arch, "browser") ? - "client" : "server"; - - var api = { - /** - * In the legacy extension API, this is the ultimate low-level - * entry point to add data to the bundle. - * - * type: "js", "css", "head", "body", "static" - * - * path: the (absolute) path at which the file will be - * served. ignored in the case of "head" and "body". - * - * source_file: the absolute path to read the data from. if - * path is set, will default based on that. overridden by - * data. - * - * data: the data to send. overrides source_file if - * present. you must still set path (except for "head" and - * "body".) - */ - add_resource: function (options) { - var sourceFile = options.source_file || options.path; - - var data; - if (options.data) { - data = options.data; - if (!(data instanceof Buffer)) { - if (!(typeof data === "string")) { - buildmessage.error("bad type for 'data'", - { useMyCaller: true }); - // recover by ignoring resource - return; - } - data = new Buffer(data, 'utf8'); - } - } else { - if (!sourceFile) { - buildmessage.error("need either 'source_file' or 'data'", - { useMyCaller: true }); - // recover by ignoring resource - return; - } - data = fs.readFileSync(sourceFile); - } - - if (options.where && options.where !== clientOrServer) { - buildmessage.error("'where' is deprecated here and if " + - "provided must be '" + clientOrServer + "'", - { useMyCaller: true }); - // recover by ignoring resource - return; - } - - var relPath = path.relative(compileStep.rootOutputPath, - options.path); - if (options.type === "js") - compileStep.addJavaScript({ path: relPath, - data: data.toString('utf8') }); - else if (options.type === "head" || options.type === "body") - compileStep.appendDocument({ section: options.type, - data: data.toString('utf8') }); - else if (options.type === "css") - compileStep.addStylesheet({ path: relPath, - data: data.toString('utf8') }); - else if (options.type === "static") - compileStep.addAsset({ path: relPath, data: data }); - }, - - error: function (message) { - buildmessage.error(message, { useMyCaller: true }); - // recover by just continuing - } - }; - - // old-school extension can only take the input as a file on - // disk, so write it out to a temporary file for them. take - // care to preserve the original extension since some legacy - // plugins depend on that (coffeescript.) Also (sigh) put it - // in the same directory as the original file so that - // relative paths work for include files, for plugins that - // care about that. - var tmpdir = path.resolve(path.dirname(compileStep._fullInputPath)); - do { - var tempFilePath = - path.join(tmpdir, "build" + - Math.floor(Math.random() * 1000000) + - "." + path.basename(compileStep.inputPath)); - } while (fs.existsSync(tempFilePath)); - var tempFile = fs.openSync(tempFilePath, "wx"); - try { - var data = compileStep.read(); - fs.writeSync(tempFile, data, 0, data.length); - } finally { - fs.closeSync(tempFile); - } - - try { - callback(api, tempFilePath, - path.join(compileStep.rootOutputPath, - compileStep.inputPath), - clientOrServer); - } finally { - fs.unlinkSync(tempFilePath); - } - }; + register_extension: function () { + buildmessage.error( + "Package.register_extension() is no longer supported. Use " + + "Package._transitional_registerBuildPlugin instead.", + { useMyCaller: true }); + // recover by ignoring }, // Define a plugin. A plugin extends the build process for @@ -1412,7 +1292,6 @@ _.extend(Package.prototype, { // principle of least surprise.) Leave the metadata if we have // it, though. roleHandlers = {use: null, test: null}; - self.legacyExtensionHandlers = {}; self.pluginInfo = {}; npmDependencies = null; } @@ -1649,7 +1528,6 @@ _.extend(Package.prototype, { sources = {use: {client: [], server: []}, test: {client: [], server: []}}; roleHandlers = {use: null, test: null}; - self.legacyExtensionHandlers = {}; self.pluginInfo = {}; npmDependencies = null; } @@ -2160,7 +2038,7 @@ _.extend(Package.prototype, { // True if this package can be saved as a unipackage canBeSavedAsUnipackage: function () { var self = this; - return _.keys(self.legacyExtensionHandlers || []).length === 0; + return true; }, // options: From 8df6553ce2e26ec0a3b29c6186c1f2f6c506f428 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Sun, 8 Sep 2013 22:27:08 +0200 Subject: [PATCH 045/335] Add a literate handler in the coffeescript package Add .coffee.md handling tests --- packages/coffeescript/coffeescript_tests.js | 3 ++- packages/coffeescript/package.js | 7 ++++--- .../coffeescript/plugin/compile-coffeescript.js | 17 +++++++++++++---- .../coffeescript_strict_tests.coffee | 0 .../{ => tests}/coffeescript_tests.coffee | 0 .../tests/litcoffeescript_tests.coffee.md | 6 ++++++ .../{ => tests}/litcoffeescript_tests.litcoffee | 0 7 files changed, 25 insertions(+), 8 deletions(-) rename packages/coffeescript/{ => tests}/coffeescript_strict_tests.coffee (100%) rename packages/coffeescript/{ => tests}/coffeescript_tests.coffee (100%) create mode 100644 packages/coffeescript/tests/litcoffeescript_tests.coffee.md rename packages/coffeescript/{ => tests}/litcoffeescript_tests.litcoffee (100%) diff --git a/packages/coffeescript/coffeescript_tests.js b/packages/coffeescript/coffeescript_tests.js index 6baa3ca189..dad72f95fa 100644 --- a/packages/coffeescript/coffeescript_tests.js +++ b/packages/coffeescript/coffeescript_tests.js @@ -3,9 +3,10 @@ Tinytest.add("coffeescript - presence", function(test) { }); Tinytest.add("literate coffeescript - presence", function(test) { test.isTrue(Meteor.__LITCOFFEESCRIPT_PRESENT); + test.isTrue(Meteor.__COFFEEMDSCRIPT_PRESENT); }); Tinytest.add("coffeescript - exported variable", function(test) { test.equal(COFFEESCRIPT_EXPORTED, 123); test.equal(Package['coffeescript-test-helper'].COFFEESCRIPT_EXPORTED, 123); -}); +}); \ No newline at end of file diff --git a/packages/coffeescript/package.js b/packages/coffeescript/package.js index 2a13f962fb..e31e47b9db 100644 --- a/packages/coffeescript/package.js +++ b/packages/coffeescript/package.js @@ -16,9 +16,10 @@ Package.on_test(function (api) { api.use(['coffeescript-test-helper'], ['client', 'server']); api.add_files([ 'coffeescript_test_setup.js', - 'coffeescript_tests.coffee', - 'coffeescript_strict_tests.coffee', - 'litcoffeescript_tests.litcoffee', + 'tests/coffeescript_tests.coffee', + 'tests/coffeescript_strict_tests.coffee', + 'tests/litcoffeescript_tests.litcoffee', + 'tests/litcoffeescript_tests.coffee.md', 'coffeescript_tests.js' ], ['client', 'server']); }); diff --git a/packages/coffeescript/plugin/compile-coffeescript.js b/packages/coffeescript/plugin/compile-coffeescript.js index 3270a2912e..27b27464ba 100644 --- a/packages/coffeescript/plugin/compile-coffeescript.js +++ b/packages/coffeescript/plugin/compile-coffeescript.js @@ -113,13 +113,17 @@ var addSharedHeader = function (source, sourceMap) { }; }; -var handler = function (compileStep) { +var handler = function (compileStep, isLiterate) { + if (typeof isLiterate === "undefined") + var isLiterate = false; + var source = compileStep.read().toString('utf8'); var outputFile = compileStep.inputPath + ".js"; + var options = { bare: true, filename: compileStep.inputPath, - literate: path.extname(compileStep.inputPath) === '.litcoffee', + literate: isLiterate, // Return a source map. sourceMap: true, // Include the original source in the source map (sourcesContent field). @@ -152,6 +156,11 @@ var handler = function (compileStep) { }); }; -Plugin.registerSourceHandler("coffee", handler); -Plugin.registerSourceHandler("litcoffee", handler); +var literateHandler = function (compileStep) { + return handler(compileStep, true); +} + +Plugin.registerSourceHandler("coffee", handler); +Plugin.registerSourceHandler("litcoffee", literateHandler); +Plugin.registerSourceHandler("coffee.md", literateHandler); diff --git a/packages/coffeescript/coffeescript_strict_tests.coffee b/packages/coffeescript/tests/coffeescript_strict_tests.coffee similarity index 100% rename from packages/coffeescript/coffeescript_strict_tests.coffee rename to packages/coffeescript/tests/coffeescript_strict_tests.coffee diff --git a/packages/coffeescript/coffeescript_tests.coffee b/packages/coffeescript/tests/coffeescript_tests.coffee similarity index 100% rename from packages/coffeescript/coffeescript_tests.coffee rename to packages/coffeescript/tests/coffeescript_tests.coffee diff --git a/packages/coffeescript/tests/litcoffeescript_tests.coffee.md b/packages/coffeescript/tests/litcoffeescript_tests.coffee.md new file mode 100644 index 0000000000..e68b845a96 --- /dev/null +++ b/packages/coffeescript/tests/litcoffeescript_tests.coffee.md @@ -0,0 +1,6 @@ +This file is just the same as `coffeescript_tests.coffee`, first we set a +property, which we check for in `coffeescript_tests.js`, and then a trivial +testcase. + + Meteor.__COFFEEMDSCRIPT_PRESENT = true + Tinytest.add "markdown coffeescript - compile", (test) -> test.isTrue true diff --git a/packages/coffeescript/litcoffeescript_tests.litcoffee b/packages/coffeescript/tests/litcoffeescript_tests.litcoffee similarity index 100% rename from packages/coffeescript/litcoffeescript_tests.litcoffee rename to packages/coffeescript/tests/litcoffeescript_tests.litcoffee From d4186524b85395813a777444229e64d48fefa546 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Sun, 8 Sep 2013 23:05:30 +0200 Subject: [PATCH 046/335] Support multiple extensions in _getSourceHandler --- tools/packages.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tools/packages.js b/tools/packages.js index 09290ac63d..fe60ed384b 100644 --- a/tools/packages.js +++ b/tools/packages.js @@ -236,8 +236,8 @@ _.extend(Slice.prototype, { var relPath = source.relPath; var fileOptions = _.clone(source.fileOptions) || {}; var absPath = path.resolve(self.pkg.sourceRoot, relPath); - var ext = path.extname(relPath).substr(1); - var handler = !fileOptions.isAsset && self._getSourceHandler(ext); + var filename = path.basename(relPath, ''); + var handler = !fileOptions.isAsset && self._getSourceHandler(filename); var contents = watch.readAndWatchFile(self.watchSet, absPath); if (contents === null) { @@ -723,12 +723,17 @@ _.extend(Slice.prototype, { // Find the function that should be used to handle a source file for // this slice, or return null if there isn't one. We'll use handlers - // that are defined in this package and in its immediate - // dependencies. ('extension' should be the extension of the file - // without a leading dot.) - _getSourceHandler: function (extension) { + // that are defined in this package and in its immediate dependencies. + _getSourceHandler: function (filename) { var self = this; - return (self._allHandlers())[extension] || null; + var handlers = self._allHandlers(); + var parts = filename.split('.'); + for (var i = 0; i < parts.length; i++) { + var extension = parts.slice(i).join('.'); + if (extension in handlers) + return handlers[extension]; + } + return null; } }); @@ -946,7 +951,8 @@ _.extend(Package.prototype, { return; var Plugin = { - // 'extension' is a file extension without a dot (eg 'js', 'coffee') + // 'extension' is a file extension without the separation dot + // (eg 'js', 'coffee', 'coffee.md') // // 'handler' is a function that takes a single argument, a // CompileStep (#CompileStep) From c66b8e3db5a541c8388d773261dc6fc5f2e55fee Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Thu, 12 Sep 2013 23:05:39 +0200 Subject: [PATCH 047/335] Style tweaks --- packages/coffeescript/plugin/compile-coffeescript.js | 5 +---- tools/packages.js | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/coffeescript/plugin/compile-coffeescript.js b/packages/coffeescript/plugin/compile-coffeescript.js index 27b27464ba..b0c94ce870 100644 --- a/packages/coffeescript/plugin/compile-coffeescript.js +++ b/packages/coffeescript/plugin/compile-coffeescript.js @@ -114,16 +114,13 @@ var addSharedHeader = function (source, sourceMap) { }; var handler = function (compileStep, isLiterate) { - if (typeof isLiterate === "undefined") - var isLiterate = false; - var source = compileStep.read().toString('utf8'); var outputFile = compileStep.inputPath + ".js"; var options = { bare: true, filename: compileStep.inputPath, - literate: isLiterate, + literate: !!isLiterate, // Return a source map. sourceMap: true, // Include the original source in the source map (sourcesContent field). diff --git a/tools/packages.js b/tools/packages.js index fe60ed384b..adedc992e1 100644 --- a/tools/packages.js +++ b/tools/packages.js @@ -236,7 +236,7 @@ _.extend(Slice.prototype, { var relPath = source.relPath; var fileOptions = _.clone(source.fileOptions) || {}; var absPath = path.resolve(self.pkg.sourceRoot, relPath); - var filename = path.basename(relPath, ''); + var filename = path.basename(relPath); var handler = !fileOptions.isAsset && self._getSourceHandler(filename); var contents = watch.readAndWatchFile(self.watchSet, absPath); @@ -730,7 +730,7 @@ _.extend(Slice.prototype, { var parts = filename.split('.'); for (var i = 0; i < parts.length; i++) { var extension = parts.slice(i).join('.'); - if (extension in handlers) + if (_has(handlers, extension) return handlers[extension]; } return null; From 259fb036c8b083a1387b26a38474094e4ff0c7d9 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Thu, 12 Sep 2013 23:23:31 +0200 Subject: [PATCH 048/335] Fix syntax --- tools/packages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/packages.js b/tools/packages.js index adedc992e1..41832c04fd 100644 --- a/tools/packages.js +++ b/tools/packages.js @@ -730,7 +730,7 @@ _.extend(Slice.prototype, { var parts = filename.split('.'); for (var i = 0; i < parts.length; i++) { var extension = parts.slice(i).join('.'); - if (_has(handlers, extension) + if (_.has(handlers, extension)) return handlers[extension]; } return null; From 2e10d43041be13eb19269f7341cd74ebfca0f30d Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Mon, 9 Sep 2013 14:48:39 -0400 Subject: [PATCH 049/335] Simple tests for sub-set projections. --- packages/minimongo/minimongo_tests.js | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 2521576bf5..3c736df182 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -1069,6 +1069,55 @@ Tinytest.add("minimongo - fetch with fields", function (test) { }); }); +Tinytest.add("minimongo - fetch with projection, subarrays", function (test) { + // Apparently projection of type 'foo.bar.x' for + // { foo: [ { bar: { x: 42 } }, { bar: { x: 3 } } ] } + // should return exactly this object. More precisely, arrays are considered as + // sets and are queried separately and then merged back to result set + var c = new LocalCollection(); + + // Insert a test object with two set fields + c.insert({ + setA: [{ + fieldA: 42, + fieldB: 33 + }, { + fieldA: "the good", + fieldB: "the bad", + fieldC: "the ugly" + }], + setB: [{ + anotherA: { }, + anotherB: "meh" + }, { + anotherA: 1234, + anotherB: 431 + }] + }); + + var equalNonStrict = function (a, b, desc) { + test.equal(EJSON.stringify(a), EJSON.stringify(b), desc); + }; + + var testForProjection = function (projection, expected) { + var fetched = c.find({}, { fields: projection }).fetch()[0]; + equalNonStrict(fetched, expected, "failed sub-set projection: " + + JSON.stringify(projection)); + }; + + testForProjection({ 'setA.fieldA': 1, 'setB.anotherB': 1, _id: 0 }, + { + setA: [{ fieldA: 42 }, { fieldA: "the good" }], + setB: [{ anotherB: "meh" }, { anotherB: 431 }] + }); + + testForProjection({ 'setA.fieldA': 0, 'setB.anotherA': 0, _id: 0 }, + { + setA: [{fieldB:33}, {fieldB:"the bad",fieldC:"the ugly"}], + setB: [{ anotherB: "meh" }, { anotherB: 431 }] + }); +}); + Tinytest.add("minimongo - observe ordered with projection", function (test) { // These tests are copy-paste from "minimongo -observe ordered", // slightly modified to test projection From 27fd0d506636687c72e7dd860752cd6ada2bb0bb Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Mon, 9 Sep 2013 18:16:23 -0400 Subject: [PATCH 050/335] Support subsets fields filtering. Rewrote the returned closure. Still don't support '$' and '$slice' operators. --- packages/minimongo/minimongo.js | 111 ++++++++++++-------------------- 1 file changed, 42 insertions(+), 69 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 91cfffb794..bf89da0771 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -623,7 +623,7 @@ LocalCollection.prototype._modifyAndNotify = function ( // in the output. So it's safe to skip recompute if neither before or // after are true.) if (before || after) - recomputeQids[qid] = true; + recomputeQids[qid] = true; } else if (before && !after) { LocalCollection._removeFromResults(query, doc); } else if (!before && after) { @@ -1069,7 +1069,7 @@ LocalCollection._compileProjection = function (fields) { var _idProjection = _.isUndefined(fields._id) ? true : fields._id; delete fields._id; var including = null; // Unknown - var projectionRules = []; + var projectionRulesTree = {}; // Tree represented as nested objects _.each(fields, function (rule, keyPath) { rule = !!rule; @@ -1078,74 +1078,47 @@ LocalCollection._compileProjection = function (fields) { if (including !== rule) // This error message is copies from MongoDB shell throw MinimongoError("You cannot currently mix including and excluding fields."); - projectionRules.push(keyPath.split('.')); + var treePos = projectionRulesTree; + keyPath = keyPath.split('.'); + + _.each(keyPath.slice(0, -1), function (key) { + if (!_.has(treePos, key)) + treePos[key] = {}; + treePos = treePos[key]; + }); + + treePos[_.last(keyPath)] = including; }); - // XXX do these functions share too much in common? - if (including) - return function (doc) { - var result = {}; + // returns transformed doc according to ruleTree + var transform = function (doc, ruleTree) { + var res = including ? {} : EJSON.clone(doc); + _.each(ruleTree, function (rule, key) { + if (!_.has(doc, key)) + return; + if (_.isObject(rule)) { + if (_.isArray(doc[key])) + res[key] = _.map(doc[key], function (subdoc) { + return transform(subdoc, rule); + }); + else if (_.isObject(doc[key])) + res[key] = transform(doc[key], rule); + } else if (including) + res[key] = doc[key]; + else + delete res[key]; + }); + + return res; + }; - _.each(projectionRules, function (keyPath) { - var target = result; - var docTarget = doc; - for (var i = 0; i < keyPath.length - 1; i++) { - var key = keyPath[i]; - // This block simulates MongoDB behavior for different edge-cases when - // object on certain path wasn't found or array found instead of an - // object, or vice-versa. - if (!_.has(target, key)) { - if (_.isArray(docTarget[key])) { - target[key] = []; - docTarget = undefined; - break; - } else if (_.isObject(docTarget[key])) - target[key] = {}; - else { - docTarget = undefined; - break; - } - } - - target = target[key]; - docTarget = docTarget[key]; - } - - if (keyPath.length > 0 && docTarget && _.has(docTarget, _.last(keyPath))) - target[_.last(keyPath)] = docTarget[_.last(keyPath)]; - }); - - if (_idProjection && _.has(doc, '_id')) - result._id = doc._id; - - return result; - }; - else - return function (doc) { - // XXX Deep copy on this level might be a slowing factor, - // In fact we need it only in case of nested excluded fields. - var result = EJSON.clone(doc); - - _.each(projectionRules, function (keyPath) { - var target = result; - var docTarget = doc; - for (var i = 0; i < keyPath.length - 1; i++) { - var key = keyPath[i]; - if (!_.has(target, key)) { - break; - } - - target = target[key]; - docTarget = docTarget[key]; - } - - if (keyPath.length > 0) - delete target[_.last(keyPath)]; - }); - - if (!_idProjection) - delete result._id; - - return result; - }; + return function (obj) { + var res = transform(obj, projectionRulesTree); + + if (_idProjection && _.has(obj, '_id')) + res._id = obj._id; + if (!_idProjection && _.has(res, '_id')) + delete res._id; + return res; + }; }; From b24066d47785e33f7c4f0567930befbaa4b9cf85 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Mon, 9 Sep 2013 18:33:44 -0400 Subject: [PATCH 051/335] Improve documentation on fields. - You can mix incl/excl as long as it is `_id` - Note unsupported `$` and `$slice` operators of minimongo - Give an advanced example with reference to mongodb docs. --- docs/client/api.html | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/client/api.html b/docs/client/api.html index 94d75eea0d..72775c7da3 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -1262,7 +1262,20 @@ the value. The `_id` field is still included in the result. // Users.find({}, {fields: {firstname: 1, lastname: 1}}) -It is not possible to mix inclusion and exclusion styles. +It is not possible to mix inclusion and exclusion styles (exception for `_id`). +Field operators such as `$` and `$slice` are not available on client side yet. + +More advanced example: + + Users.insert({ alterEgos: [{ name: "Kira", alliance: "murderer" }, + { name: "L", alliance: "police" }], + name: "Yagami Light" }); + + Users.findOne({}, { fields: { 'alterEgos.name': 1, _id: 0 } }); + + // returns { alterEgos: [{ name: "Kira" }, { name: "L" }] } + +Notice the nested field rule and array behavior, learn more in MongoDB docs. {{/api_box_inline}} From f6e307e644223fa3bf90a1c09699a6997075da85 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Mon, 9 Sep 2013 20:43:46 -0700 Subject: [PATCH 052/335] Whitespace --- packages/minimongo/minimongo.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index bf89da0771..49501f04e4 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -1108,13 +1108,13 @@ LocalCollection._compileProjection = function (fields) { else delete res[key]; }); - + return res; }; return function (obj) { var res = transform(obj, projectionRulesTree); - + if (_idProjection && _.has(obj, '_id')) res._id = obj._id; if (!_idProjection && _.has(res, '_id')) From 83136f64aad53aa70058ae3f59137f674535f488 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Tue, 10 Sep 2013 02:54:53 -0700 Subject: [PATCH 053/335] Add nested-nested test. Taken from Glasser's old code-review. --- packages/minimongo/minimongo_tests.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 3c736df182..c55dbfa4b6 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -1116,6 +1116,12 @@ Tinytest.add("minimongo - fetch with projection, subarrays", function (test) { setA: [{fieldB:33}, {fieldB:"the bad",fieldC:"the ugly"}], setB: [{ anotherB: "meh" }, { anotherB: 431 }] }); + + c.remove({}); + c.insert({a:[[{b:1,c:2},{b:2,c:4}],{b:3,c:5},[{b:4, c:9}]]}); + + testForProjection({ 'a.b': 1, _id: 0 }, + {"a" : [ [ { "b" : 1 }, { "b" : 2 } ], { "b" : 3 }, [ { "b" : 4 } ] ] }); }); Tinytest.add("minimongo - observe ordered with projection", function (test) { From 05a3a22a6b94e520dffe3385887204c8e3eb3c61 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Tue, 10 Sep 2013 17:05:20 -0700 Subject: [PATCH 054/335] Fields with subsets passes the test. --- packages/minimongo/minimongo.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 49501f04e4..6f22c5449c 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -1092,17 +1092,19 @@ LocalCollection._compileProjection = function (fields) { // returns transformed doc according to ruleTree var transform = function (doc, ruleTree) { + // Special case for "sets" + if (_.isArray(doc)) + return _.map(doc, function (subdoc) { return transform(subdoc, ruleTree); }); + var res = including ? {} : EJSON.clone(doc); _.each(ruleTree, function (rule, key) { if (!_.has(doc, key)) return; if (_.isObject(rule)) { - if (_.isArray(doc[key])) - res[key] = _.map(doc[key], function (subdoc) { - return transform(subdoc, rule); - }); - else if (_.isObject(doc[key])) + // For sub-objects/subsets we branch + if (_.isObject(doc[key])) res[key] = transform(doc[key], rule); + // Otherwise we don't even touch this subfield } else if (including) res[key] = doc[key]; else From a6643812a85e0feec9e6a76535cac8d7f69b4d8f Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Tue, 10 Sep 2013 17:12:01 -0700 Subject: [PATCH 055/335] Same test but for blacklisting --- packages/minimongo/minimongo_tests.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index c55dbfa4b6..cd9868e77e 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -1121,7 +1121,9 @@ Tinytest.add("minimongo - fetch with projection, subarrays", function (test) { c.insert({a:[[{b:1,c:2},{b:2,c:4}],{b:3,c:5},[{b:4, c:9}]]}); testForProjection({ 'a.b': 1, _id: 0 }, - {"a" : [ [ { "b" : 1 }, { "b" : 2 } ], { "b" : 3 }, [ { "b" : 4 } ] ] }); + {a: [ [ { b: 1 }, { b: 2 } ], { b: 3 }, [ { b: 4 } ] ] }); + testForProjection({ 'a.b': 0, _id: 0 }, + {a: [ [ { c: 2 }, { c: 4 } ], { c: 5 }, [ { c: 9 } ] ] }); }); Tinytest.add("minimongo - observe ordered with projection", function (test) { From eb5d426a7c7abab87557ecba4c13614997091e80 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Tue, 10 Sep 2013 17:13:48 -0700 Subject: [PATCH 056/335] Fix docs. $slice is not related to fields projection. --- docs/client/api.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client/api.html b/docs/client/api.html index 72775c7da3..3aa992a512 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -1263,7 +1263,7 @@ the value. The `_id` field is still included in the result. // Users.find({}, {fields: {firstname: 1, lastname: 1}}) It is not possible to mix inclusion and exclusion styles (exception for `_id`). -Field operators such as `$` and `$slice` are not available on client side yet. +Field operators such as `$` and `$elemMatch` are not available on client side yet. More advanced example: From 26422809776068bfa163b56877da5e39eecf2fd7 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Fri, 13 Sep 2013 01:35:05 -0700 Subject: [PATCH 057/335] Put a.b.c and a.b validation into general case. --- packages/minimongo/minimongo.js | 56 +++++++++++++++------------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 6f22c5449c..ec307a9507 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -1033,35 +1033,6 @@ LocalCollection._compileProjection = function (fields) { if (!_.isObject(fields)) throw MinimongoError("fields option must be an object"); - // Check passed projection fields' keys: - // If you have two rules such as 'foo.bar' and 'foo.bar.baz', then the - // result becomes ambiguous. If that happens, there is a probability you are - // doing something wrong, framework should notify you about such mistake - // earlier on cursor compilation step than later during runtime. - // Note, that real mongo doesn't do anything about it and the later rule - // appears in projection project, more priority it takes. - // - // Example, assume following in mongo shell: - // > db.coll.insert({ a: { b: 23, c: 44 } }) - // > db.coll.find({}, { 'a': 1, 'a.b': 1 }) - // { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23 } } - // > db.coll.find({}, { 'a.b': 1, 'a': 1 }) - // { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23, "c" : 44 } } - // - // Note, how second time the return set of keys is different. - var keyPaths = _.keys(fields); - _.each(keyPaths, function (keyPath) { - _.each(keyPaths, function (anotherKeyPath) { - var idx = keyPath.indexOf(anotherKeyPath); - // check if one key is path-prefix of another (like "abra" and - // "abra.cadabra", but not "abra" and "abrab.ra") - if (keyPath !== anotherKeyPath && !idx && keyPath[anotherKeyPath.length] === '.') - throw MinimongoError("both " + keyPath + " and " + anotherKeyPath + - " found in fields option, using both of them may trigger " + - "unexpected behavior. Did you mean to use only one of them?"); - }); - }); - if (_.any(_.values(fields), function (x) { return _.indexOf([1, 0, true, false], x) === -1; })) throw MinimongoError("Projection values should be one of 1, 0, true, or false"); @@ -1081,9 +1052,34 @@ LocalCollection._compileProjection = function (fields) { var treePos = projectionRulesTree; keyPath = keyPath.split('.'); - _.each(keyPath.slice(0, -1), function (key) { + _.each(keyPath.slice(0, -1), function (key, idx) { if (!_.has(treePos, key)) treePos[key] = {}; + else if (_.isBoolean(treePos[key])) { + // Check passed projection fields' keys: If you have two rules such as + // 'foo.bar' and 'foo.bar.baz', then the result becomes ambiguous. If + // that happens, there is a probability you are doing something wrong, + // framework should notify you about such mistake earlier on cursor + // compilation step than later during runtime. Note, that real mongo + // doesn't do anything about it and the later rule appears in projection + // project, more priority it takes. + // + // Example, assume following in mongo shell: + // > db.coll.insert({ a: { b: 23, c: 44 } }) + // > db.coll.find({}, { 'a': 1, 'a.b': 1 }) + // { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23 } } + // > db.coll.find({}, { 'a.b': 1, 'a': 1 }) + // { "_id" : ObjectId("520bfe456024608e8ef24af3"), "a" : { "b" : 23, "c" : 44 } } + // + // Note, how second time the return set of keys is different. + + var currentPath = keyPath.join('.'); + var anotherPath = keyPath.slice(0, idx + 1).join('.'); + throw MinimongoError("both " + currentPath + " and " + anotherPath + + " found in fields option, using both of them may trigger " + + "unexpected behavior. Did you mean to use only one of them?"); + } + treePos = treePos[key]; }); From 6c448a213f3b222d41420a1fe331b58f421decb1 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Fri, 13 Sep 2013 15:41:53 -0700 Subject: [PATCH 058/335] Another test --- packages/minimongo/minimongo_tests.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index cd9868e77e..66e9b51df4 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -983,6 +983,11 @@ Tinytest.add("minimongo - projection_compiler", function (test) { [ { a: { b: 42 } }, { a: { b: 42 } }, "Can't have ambiguous rules (one is prefix of another)" ] ]); }); + test.throws(function () { + testProjection({ 'a.b.c': 1, 'a.b': 1, 'a': 1 }, [ + [ { a: { b: 42 } }, { a: { b: 42 } }, "Can't have ambiguous rules (one is prefix of another)" ] + ]); + }); test.throws(function () { testProjection("some string", [ From 4ed373b2b40179c4784cee3908ad95ab4754e470 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Fri, 13 Sep 2013 15:59:36 -0700 Subject: [PATCH 059/335] Check the equality with _.isEqual EJSON.stringify doesn't guarantee the order of keys. --- packages/minimongo/minimongo_tests.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 66e9b51df4..07475e5c94 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -898,7 +898,7 @@ Tinytest.add("minimongo - projection_compiler", function (test) { var testProjection = function (projection, tests) { var projection_f = LocalCollection._compileProjection(projection); var equalNonStrict = function (a, b, desc) { - test.equal(EJSON.stringify(a), EJSON.stringify(b), desc); + test.isTrue(_.isEqual(a, b), desc); }; _.each(tests, function (testCase) { @@ -1101,7 +1101,7 @@ Tinytest.add("minimongo - fetch with projection, subarrays", function (test) { }); var equalNonStrict = function (a, b, desc) { - test.equal(EJSON.stringify(a), EJSON.stringify(b), desc); + test.isTrue(_.isEqual(a, b), desc); }; var testForProjection = function (projection, expected) { From 4345fdbd4b8179fc52becfb32028d52bae4f3d4b Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Fri, 13 Sep 2013 16:00:08 -0700 Subject: [PATCH 060/335] Always sort keys to simplify some cases. And don't modify the argument. --- packages/minimongo/minimongo.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index ec307a9507..75e5acd639 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -1038,12 +1038,12 @@ LocalCollection._compileProjection = function (fields) { throw MinimongoError("Projection values should be one of 1, 0, true, or false"); var _idProjection = _.isUndefined(fields._id) ? true : fields._id; - delete fields._id; + var fieldsKeys = _.reject(_.keys(fields).sort(), function (key) { return key === '_id'; }); var including = null; // Unknown var projectionRulesTree = {}; // Tree represented as nested objects - _.each(fields, function (rule, keyPath) { - rule = !!rule; + _.each(fieldsKeys, function (keyPath) { + var rule = !!fields[keyPath]; if (including === null) including = rule; if (including !== rule) From 48eea25d272dd41ccb315d93574b2365aff09230 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Mon, 16 Sep 2013 11:04:08 -0700 Subject: [PATCH 061/335] Comments on keys sorting. Suggested by Glasser. --- packages/minimongo/minimongo.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 75e5acd639..7fb03f7810 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -1038,6 +1038,9 @@ LocalCollection._compileProjection = function (fields) { throw MinimongoError("Projection values should be one of 1, 0, true, or false"); var _idProjection = _.isUndefined(fields._id) ? true : fields._id; + // Find the non-_id keys (_id is handled specially because it is included unless + // explicitly excluded). Sort the keys, so that our code to detect overlaps + // like 'foo' and 'foo.bar' can assume that 'foo' comes first. var fieldsKeys = _.reject(_.keys(fields).sort(), function (key) { return key === '_id'; }); var including = null; // Unknown var projectionRulesTree = {}; // Tree represented as nested objects From d5ce2502f6c67b5e8ce4b724a61553cf240367e8 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Thu, 19 Sep 2013 10:22:36 -0700 Subject: [PATCH 062/335] Improve error messages if unipackage load fails. This mostly affects Meteor core development (since for now, unipackage.load is only used inside the meteor tools itself and only to load core packages, which should be properly compiled in any Meteor release). --- tools/unipackage.js | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tools/unipackage.js b/tools/unipackage.js index ff4d9e5786..5e49b8753b 100644 --- a/tools/unipackage.js +++ b/tools/unipackage.js @@ -1,6 +1,7 @@ var _ = require('underscore'); var library = require('./library.js'); var bundler = require('./bundler.js'); +var buildmessage = require('./buildmessage.js'); // Load unipackages into the currently running node.js process. Use // this to use unipackages (such as the DDP client) from command-line @@ -69,16 +70,27 @@ var load = function (options) { __meteor_runtime_config__: { meteorRelease: options.release } }; - // Load the code - var image = bundler.buildJsImage({ - name: "load", - library: options.library, - use: options.packages || [] - }).image; - var ret = image.load(env); + var ret; + var messages = buildmessage.capture({ + title: "loading unipackage" + }, function () { + // Load the code + var image = bundler.buildJsImage({ + name: "load", + library: options.library, + use: options.packages || [] + }).image; + ret = image.load(env); - // Run any user startup hooks. - _.each(env.__meteor_bootstrap__.startup_hooks, function (x) { x(); }); + // Run any user startup hooks. + _.each(env.__meteor_bootstrap__.startup_hooks, function (x) { x(); }); + }); + + if (messages.hasMessages()) { + process.stdout.write("Errors prevented unipackage load:\n"); + process.stdout.write(messages.formatMessages()); + process.exit(1); + } // Save to cache cache[cacheKey] = ret; From 8afa4f2c9e70bf812a1f0689212955e3a00c6023 Mon Sep 17 00:00:00 2001 From: Maxhodges Date: Sat, 14 Sep 2013 02:47:14 +0900 Subject: [PATCH 063/335] typo. "with" repeated twice. --- scripts/admin/banner.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/admin/banner.txt b/scripts/admin/banner.txt index f0422b945e..3f4a24094b 100644 --- a/scripts/admin/banner.txt +++ b/scripts/admin/banner.txt @@ -1,5 +1,5 @@ => Meteor 0.6.5.1: a few bugfixes, including fixes for lines ending with - backslashes, filenames with with spaces, and tab characters in d3. + backslashes, filenames with spaces, and tab characters in d3. This is being downloaded in the background. Update your project to Meteor 0.6.5.1 by running 'meteor update'. From 35e8a048349e42883ab3afb8d9a79362f5e9ebd4 Mon Sep 17 00:00:00 2001 From: Naomi Seyfer Date: Mon, 23 Sep 2013 15:53:47 -0700 Subject: [PATCH 064/335] Stop offending the message. Dont alias in_queue to out_queue --- packages/livedata/livedata_server.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index fb3a28a0ed..1ecea732b4 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -428,7 +428,8 @@ _.extend(Session.prototype, { self._deactivateAllSubscriptions(); // Drop the merge box data immediately. self.collectionViews = {}; - self.in_queue = self.out_queue = []; + self.in_queue = []; + self.out_queue = []; }, // Send a message (queueing it if no socket is connected right now.) From 4f4e5342eb6767f3837d43689f9618b46345e8a1 Mon Sep 17 00:00:00 2001 From: Naomi Seyfer Date: Mon, 23 Sep 2013 17:30:34 -0700 Subject: [PATCH 065/335] Remove code to keep around sessions after they are disconnected. We will deal with reconnects with session resumption someday, but it will not be using this dead code which has bitrotted in our repository for months on end. It was never fully implemented, and what we had was a sketch that causes bugs (and extra cpu usage). --- packages/livedata/livedata_server.js | 223 ++++++++------------------- 1 file changed, 63 insertions(+), 160 deletions(-) diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 1ecea732b4..11bd6752a5 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -212,7 +212,7 @@ _.extend(SessionCollectionView.prototype, { /* Session */ /******************************************************************************/ -var Session = function (server, version) { +var Session = function (server, version, socket) { var self = this; self.id = Random.id(); @@ -220,18 +220,11 @@ var Session = function (server, version) { self.version = version; self.initialized = false; - self.socket = null; - self.last_connect_time = 0; - self.last_detach_time = +(new Date); + self.socket = socket; - self.in_queue = []; + self.inQueue = []; self.blocked = false; - self.worker_running = false; - - self.out_queue = []; - - // id of invocation => {result or error, when} - self.result_cache = {}; + self.workerRunning = false; // Sub objects for active subscriptions self._namedSubs = {}; @@ -257,6 +250,13 @@ var Session = function (server, version) { // when we are rerunning subscriptions, any ready messages // we want to buffer up for when we are done rerunning subscriptions self._pendingReady = []; + + socket.send(stringifyDDP({msg: 'connected', + session: self.id})); + // On initial connect, spin up all the universal publishers. + Fiber(function () { + self.startUniversalSubs(); + }).run(); }; _.extend(Session.prototype, { @@ -340,32 +340,7 @@ _.extend(Session.prototype, { var view = self.getCollectionView(collectionName); view.changed(subscriptionHandle, id, fields); }, - // Connect a new socket to this session, displacing (and closing) - // any socket that was previously connected - connect: function (socket) { - var self = this; - if (self.socket) { - self.socket.close(); - self.detach(self.socket); - } - self.socket = socket; - self.last_connect_time = +(new Date); - _.each(self.out_queue, function (msg) { - if (Meteor._printSentDDP) - Meteor._debug("Sent DDP", stringifyDDP(msg)); - self.socket.send(stringifyDDP(msg)); - }); - self.out_queue = []; - - // On initial connect, spin up all the universal publishers. - if (!self.initialized) { - self.initialized = true; - Fiber(function () { - self.startUniversalSubs(); - }).run(); - } - }, startUniversalSubs: function () { var self = this; @@ -378,70 +353,33 @@ _.extend(Session.prototype, { }); }, - // If 'socket' is the socket currently connected to this session, - // detach it (the session will then have no socket -- it will - // continue running and queue up its messages.) If 'socket' isn't - // the currently connected socket, just clean up the pointer that - // may have led us to believe otherwise. - detach: function (socket) { - var self = this; - if (socket === self.socket) { - self.socket = null; - self.last_detach_time = +(new Date); - } - if (socket.meteor_session === self) - socket.meteor_session = null; - }, - - // Should be called periodically to prune the method invocation - // replay cache. - cleanup: function () { - var self = this; - // Only prune if we're connected, and we've been connected for at - // least five minutes. That seems like enough time for the client - // to finish its reconnection. Then, keep five minutes of - // history. That seems like enough time for the client to receive - // our responses, or else for us to notice that the connection is - // gone. - var now = +(new Date); - if (!(self.socket && (now - self.last_connect_time) > 5 * 60 * 1000)) - return; // not connected, or not connected long enough - - var kill = []; - _.each(self.result_cache, function (info, id) { - if (now - info.when > 5 * 60 * 1000) - kill.push(id); - }); - _.each(kill, function (id) { - delete self.result_cache[id]; - }); - }, - // Destroy this session. Stop all processing and tear everything // down. If a socket was attached, close it. destroy: function () { var self = this; if (self.socket) { self.socket.close(); - self.detach(self.socket); + self.socket._meteorSession = null; } - self._deactivateAllSubscriptions(); + Meteor.defer(function () { + // stop callbacks can yield, so we defer this on destroy. + // see also _closeAllForTokens and its desire to destroy things in a loop. + self._deactivateAllSubscriptions(); + }); // Drop the merge box data immediately. self.collectionViews = {}; - self.in_queue = []; - self.out_queue = []; + self.inQueue = null; }, - // Send a message (queueing it if no socket is connected right now.) + // Send a message (doing nothing if no socket is connected right now.) // It should be a JSON object (it will be stringified.) send: function (msg) { var self = this; - if (Meteor._printSentDDP) - Meteor._debug("Sent DDP", stringifyDDP(msg)); - if (self.socket) + if (self.socket) { + if (Meteor._printSentDDP) + Meteor._debug("Sent DDP", stringifyDDP(msg)); self.socket.send(stringifyDDP(msg)); - else - self.out_queue.push(msg); + } }, // Send a connection error. @@ -468,20 +406,20 @@ _.extend(Session.prototype, { // way, but it's the easiest thing that's correct. (unsub needs to // be ordered against sub, methods need to be ordered against each // other.) - processMessage: function (msg_in, socket) { + processMessage: function (msg_in) { var self = this; - if (socket !== self.socket) + if (!self.inQueue) // we have been destroyed. return; - self.in_queue.push(msg_in); - if (self.worker_running) + self.inQueue.push(msg_in); + if (self.workerRunning) return; - self.worker_running = true; + self.workerRunning = true; var processNext = function () { - var msg = self.in_queue.shift(); + var msg = self.inQueue && self.inQueue.shift(); if (!msg) { - self.worker_running = false; + self.workerRunning = false; return; } @@ -569,18 +507,6 @@ _.extend(Session.prototype, { msg: 'updated', methods: [msg.id]}); }); - // check for a replayed method (this is important during - // reconnect) - if (_.has(self.result_cache, msg.id)) { - // found -- just resend whatever we sent last time - var payload = _.clone(self.result_cache[msg.id]); - delete payload.when; - self.send( - _.extend({msg: 'result', id: msg.id}, payload)); - fence.arm(); - return; - } - // find the handler var handler = self.server.method_handlers[msg.method]; if (!handler) { @@ -628,7 +554,6 @@ _.extend(Session.prototype, { var payload = exception ? {error: exception} : (result !== undefined ? {result: result} : {}); - self.result_cache[msg.id] = _.extend({when: +(new Date)}, payload); self.send(_.extend({msg: 'result', id: msg.id}, payload)); } }, @@ -728,6 +653,7 @@ _.extend(Session.prototype, { } }); + // XXX figure out the login token that was just used, and set up an observe // on the user doc so that deleting the user or the login token disconnects // the session. For now, if you want to make sure that your deleted users @@ -1050,7 +976,7 @@ Server = function () { self.stream_server.register(function (socket) { // socket implements the SockJSConnection interface - socket.meteor_session = null; + socket._meteorSession = null; var sendError = function (reason, offendingMessage) { var msg = {msg: 'error', reason: reason}; @@ -1076,7 +1002,7 @@ Server = function () { } if (msg.msg === 'connect') { - if (socket.meteor_session) { + if (socket._meteorSession) { sendError("Already connected", msg); return; } @@ -1084,11 +1010,11 @@ Server = function () { return; } - if (!socket.meteor_session) { + if (!socket._meteorSession) { sendError('Must connect first', msg); return; } - socket.meteor_session.processMessage(msg, socket); + socket._meteorSession.processMessage(msg); } catch (e) { // XXX print stack nicely Meteor._debug("Internal exception while processing message", msg, @@ -1097,36 +1023,13 @@ Server = function () { }); socket.on('close', function () { - if (socket.meteor_session) - socket.meteor_session.detach(socket); - }); - }); - - // Every minute, clean up sessions that have been abandoned for a - // minute. Also run result cache cleanup. - // XXX at scale, we'll want to have a separate timer for each - // session, and stagger them - // XXX when we get resume working again, we might keep sessions - // open longer (but stop running their diffs!) - Meteor.setInterval(function () { - var now = +(new Date); - var destroyedIds = []; - _.each(self.sessions, function (s, id) { - s.cleanup(); - if (!s.socket && (now - s.last_detach_time) > 60 * 1000) { - s.destroy(); - destroyedIds.push(id); + if (socket._meteorSession) { + Fiber(function () { + self._destroySession(socket._meteorSession); + }).run(); } }); - _.each(destroyedIds, function (id) { - var session = self.sessions[id]; - self.sessionsByLoginToken[session.loginToken] = _.without( - self.sessionsByLoginToken[session.loginToken], - id - ); - delete self.sessions[id]; - }); - }, 1 * 60 * 1000); + }); }; _.extend(Server.prototype, { @@ -1134,19 +1037,13 @@ _.extend(Server.prototype, { _handleConnect: function (socket, msg) { var self = this; // In the future, handle session resumption: something like: - // socket.meteor_session = self.sessions[msg.session] + // socket._meteorSession = self.sessions[msg.session] var version = calculateVersion(msg.support, SUPPORTED_DDP_VERSIONS); if (msg.version === version) { // Creating a new session - socket.meteor_session = new Session(self, version); - self.sessions[socket.meteor_session.id] = socket.meteor_session; - - - socket.send(stringifyDDP({msg: 'connected', - session: socket.meteor_session.id})); - // will kick off previous connection, if any - socket.meteor_session.connect(socket); + socket._meteorSession = new Session(self, version, socket); + self.sessions[socket._meteorSession.id] = socket._meteorSession; } else if (!msg.version) { // connect message without a version. This means an old (pre-pre1) // client is trying to connect. If we just disconnect the @@ -1240,6 +1137,21 @@ _.extend(Server.prototype, { } }, + _destroySession: function (session) { + var self = this; + delete self.sessions[session.id]; + if (session.sessionData.loginToken) { + self.sessionsByLoginToken[session.sessionData.loginToken] = _.without( + self.sessionsByLoginToken[session.sessionData.loginToken], + session.id + ); + if (_.isEmpty(self.sessionsByLoginToken[session.sessionData.loginToken])) { + delete self.sessionsByLoginToken[session.sessionData.loginToken]; + } + } + session.destroy(); + }, + methods: function (methods) { var self = this; _.each(methods, function (func, name) { @@ -1349,6 +1261,8 @@ _.extend(Server.prototype, { self.sessionsByLoginToken[oldToken], session.id ); + if (_.isEmpty(self.sessionsByLoginToken[oldToken])) + delete self.sessionsByLoginToken[oldToken]; } if (! _.has(self.sessionsByLoginToken, newToken)) self.sessionsByLoginToken[newToken] = []; @@ -1361,25 +1275,14 @@ _.extend(Server.prototype, { var self = this; _.each(tokens, function (token) { if (_.has(self.sessionsByLoginToken, token)) { - var destroyedIds = []; - _.each(self.sessionsByLoginToken[token], function (sessionId) { + // _destroySession modifies sessionsByLoginToken, so we clone it. + _.each(EJSON.clone(self.sessionsByLoginToken[token]), function (sessionId) { // Destroy session and remove from self.sessions. var session = self.sessions[sessionId]; if (session) { - session.cleanup(); - session.destroy(); - delete self.sessions[sessionId]; + self._destroySession(session); } - destroyedIds.push(sessionId); }); - - // Remove destroyed sessions from self.sessionsByLoginToken. - self.sessionsByLoginToken[token] = _.filter( - self.sessionsByLoginToken[token], - function (sessionId) { - return _.indexOf(destroyedIds, sessionId) === -1; - } - ); } }); } From 7e8c2f9405b4ce06a047878123cb0b0ce70e62c1 Mon Sep 17 00:00:00 2001 From: Avital Oliver Date: Mon, 23 Sep 2013 17:49:07 -0700 Subject: [PATCH 066/335] Better error when passing string to {{#each}} Fixes #722 --- packages/templating/deftemplate.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js index 7aafefcb1b..6ad46037f1 100644 --- a/packages/templating/deftemplate.js +++ b/packages/templating/deftemplate.js @@ -15,6 +15,15 @@ var hookHandlebars = function () { var orig = Handlebars._default_helpers.each; Handlebars._default_helpers.each = function (arg, options) { + var isArgValid = function () { + return !arg // falsey + || (arg instanceof Array) + || (arg instanceof Object && 'observeChanges' in arg); + }; + if (!isArgValid()) + throw new Error("{{#each}} only accepts arrays, cursors, or falsey " + + "values. You passed: " + arg); + // if arg isn't an observable (like LocalCollection.Cursor), // don't use this reactive implementation of #each. if (!(arg && 'observeChanges' in arg)) From ca080b147701bf2171b99336690514cb70d12ae7 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Mon, 23 Sep 2013 20:53:09 -0700 Subject: [PATCH 067/335] bump mongo and dev bundle version. --- meteor | 2 +- scripts/generate-dev-bundle.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/meteor b/meteor index d41941d01f..5584e6c2a4 100755 --- a/meteor +++ b/meteor @@ -1,6 +1,6 @@ #!/bin/bash -BUNDLE_VERSION=0.3.13 +BUNDLE_VERSION=0.3.15 # OS Check. Put here because here is where we download the precompiled # bundles that are arch specific. diff --git a/scripts/generate-dev-bundle.sh b/scripts/generate-dev-bundle.sh index 65268a7e96..0091c5a388 100755 --- a/scripts/generate-dev-bundle.sh +++ b/scripts/generate-dev-bundle.sh @@ -161,7 +161,7 @@ make install # click 'changelog' under the current version, then 'release notes' in # the upper right. cd "$DIR/build" -MONGO_VERSION="2.4.4" +MONGO_VERSION="2.4.6" # We use Meteor fork since we added some changes to the building script. # Our patches allow us to link most of the libraries statically. From 6452c8b173123f26c2442bdcc7a08cd44e9ba089 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Mon, 23 Sep 2013 22:50:43 -0700 Subject: [PATCH 068/335] update history. --- History.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/History.md b/History.md index 5e6cd7fd65..ec5978c7b5 100644 --- a/History.md +++ b/History.md @@ -12,6 +12,10 @@ 0.6.5. (A bug prevented the 0.6.5 reimplementation of `register_extension` from working properly anyway.) +* Build Linux binaries on an older Linux machine. Meteor now supports + running on Linux machines with glibc 2.9 or newer (Ubuntu 10.04+, RHEL + and CentOS 6+, Fedora 10+, Debian 6+). + ## v0.6.5.1 * Fix syntax errors on lines that end with a backslash. #1326 From bd73b312554563145cfaa240b288e6e99919c2d8 Mon Sep 17 00:00:00 2001 From: Avital Oliver Date: Tue, 24 Sep 2013 10:48:55 -0700 Subject: [PATCH 069/335] Update History.md for fixing #722 --- History.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/History.md b/History.md index ec5978c7b5..3db302fed6 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,7 @@ ## vNEXT +* Better error when passing a string to {{#each}}. #722 + * Write dates to Mongo as ISODate rather than Integer; existing data can be converted by passing it through `new Date()`. #1228 From 33153624f69bf85af90c6642075c0e01c904a200 Mon Sep 17 00:00:00 2001 From: Naomi Seyfer Date: Tue, 24 Sep 2013 12:00:15 -0700 Subject: [PATCH 070/335] Fix bug with empty-string _id not giving you any results ever --- packages/mongo-livedata/collection.js | 4 +-- packages/mongo-livedata/mongo_driver.js | 2 +- .../mongo-livedata/mongo_livedata_tests.js | 33 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 1cea1d650e..a89b88411f 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -366,9 +366,9 @@ _.each(["insert", "update", "remove"], function (name) { args[0] = _.extend({}, args[0]); if ('_id' in args[0]) { ret = args[0]._id; - if (!(typeof ret === 'string' + if (!ret || !(typeof ret === 'string' || ret instanceof Meteor.Collection.ObjectID)) - throw new Error("Meteor requires document _id fields to be strings or ObjectIDs"); + throw new Error("Meteor requires document _id fields to be non-empty strings or ObjectIDs"); } else { ret = args[0]._id = self._makeNewID(); } diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index e3fcf835d9..deda9b9d48 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -538,7 +538,7 @@ _.extend(SynchronousCursor.prototype, { var self = this; while (true) { var doc = self._synchronousNextObject().wait(); - if (!doc || !doc._id) return null; + if (!doc || typeof doc._id === 'undefined') return null; doc = replaceTypes(doc, replaceMongoAtomWithMeteor); if (!self._cursorDescription.options.tailable) { diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 0cc15c0979..64a23991ee 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -968,6 +968,39 @@ testAsyncMulti('mongo-livedata - specified _id', [ } ]); +testAsyncMulti('mongo-livedata - empty string _id', [ + function (test, expect) { + var self = this; + self.collectionName = Random.id(); + if (Meteor.isClient) { + Meteor.call('createInsecureCollection', self.collectionName); + Meteor.subscribe('c-' + self.collectionName); + } + self.coll = new Meteor.Collection(self.collectionName); + try { + self.coll.insert({_id: "", f: "foo"}); + test.fail("Insert with an empty _id should fail"); + } catch (e) { + // ok + } + self.coll.insert({_id: "realid", f: "bar"}, expect(function (err, res) { + test.equal(res, "realid"); + })); + }, + function (test, expect) { + var self = this; + var docs = self.coll.find().fetch(); + test.equal(docs, [{_id: "realid", f: "bar"}]); + }, + function (test, expect) { + var self = this; + if (Meteor.isServer) { + self.coll._collection.insert({_id: "", f: "baz"}); + test.equal(self.coll.find().fetch().length, 2); + } + } +]); + if (Meteor.isServer) { From 63589300fedfec044c80d19f0da07ad88c54d772 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Mon, 23 Sep 2013 17:47:29 -0700 Subject: [PATCH 071/335] On MongoDB failures print 20 lines of stderr and print a special error if mongod failed to start due to incompatible version of glibc. Catch stderr before 'exit' signal Try to give a good error message on outdated glibc/libstdc++. Simplify error reporting to two lines. Print only last 20 lines of stderr. Slightly better error message. --- tools/mongo_runner.js | 11 ++++++++++- tools/run.js | 13 +++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tools/mongo_runner.js b/tools/mongo_runner.js index f40a438433..c39e2e64cf 100644 --- a/tools/mongo_runner.js +++ b/tools/mongo_runner.js @@ -172,7 +172,16 @@ exports.launch_mongo = function (app_dir, port, launch_callback, on_exit_callbac callback && callback(err); }; - proc.on('exit', on_exit_callback); + var stderrOutput = ''; + + proc.stderr.setEncoding('utf8'); + proc.stderr.on('data', function (data) { + stderrOutput += data; + }); + + proc.on('exit', function (code, signal) { + on_exit_callback(code, signal, stderrOutput); + }); proc.stdout.setEncoding('utf8'); proc.stdout.on('data', function (data) { diff --git a/tools/run.js b/tools/run.js index 8ddc3dc2ed..542485a32a 100644 --- a/tools/run.js +++ b/tools/run.js @@ -628,11 +628,15 @@ exports.run = function (context, options) { } restartServer(); }, - function (code, signal) { // On Mongo dead + function (code, signal, stderr) { // On Mongo dead if (Status.shuttingDown) { return; } - console.log("Unexpected mongo exit code " + code + ". Restarting."); + + // Print only last 20 lines of stderr. + stderr = stderr.split('\n').slice(-20).join('\n'); + + console.log(stderr + "Unexpected mongo exit code " + code + ". Restarting.\n"); // if mongo dies 3 times with less than 5 seconds between each, // declare it failed and die. @@ -645,6 +649,11 @@ exports.run = function (context, options) { if (explanation === mongoExitCodes.EXIT_NET_ERROR) console.log("\nCheck for other processes listening on port " + mongoPort + "\nor other meteors running in the same project."); + if (!explanation && /GLIBC/i.test(stderr)) + console.log("\nLooks like you are trying to run Meteor on old Linux " + + "distribution. Meteor only supports Linux with glibc " + + "version 2.9 and above. Try upgrading your distribution " + + "to the latest version."); process.exit(1); } if (mongoErrorTimer) From 9368852bdcb783e4663fb9c1875c2d7dd5c3b938 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Tue, 24 Sep 2013 16:31:44 -0700 Subject: [PATCH 072/335] Improve error message Glasser's comments --- tools/run.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/run.js b/tools/run.js index 542485a32a..e2348459e0 100644 --- a/tools/run.js +++ b/tools/run.js @@ -650,8 +650,8 @@ exports.run = function (context, options) { console.log("\nCheck for other processes listening on port " + mongoPort + "\nor other meteors running in the same project."); if (!explanation && /GLIBC/i.test(stderr)) - console.log("\nLooks like you are trying to run Meteor on old Linux " + - "distribution. Meteor only supports Linux with glibc " + + console.log("\nLooks like you are trying to run Meteor on an old Linux " + + "distribution. Meteor on Linux only supports Linux with glibc " + "version 2.9 and above. Try upgrading your distribution " + "to the latest version."); process.exit(1); From 99b9ec2da5a75c954fa5b6b73361b0176869d29e Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 24 Sep 2013 17:10:29 -0700 Subject: [PATCH 073/335] Return values of server update/remove queries to user. Thanks to @jacott. Minimongo implementations to come. --- packages/mongo-livedata/collection.js | 19 ++++++++--- packages/mongo-livedata/mongo_driver.js | 14 ++++++-- .../mongo-livedata/mongo_livedata_tests.js | 33 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index a89b88411f..01f7401cfb 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -378,9 +378,15 @@ _.each(["insert", "update", "remove"], function (name) { var wrappedCallback; if (callback) { - wrappedCallback = function (error, result) { - callback(error, !error && ret); - }; + if (name === "insert") { + // On inserts, always return the id that we generated. + wrappedCallback = function (error, result) { + callback(error, !error && ret); + }; + } else { + // For updates and removes, return whatever the collection returned. + wrappedCallback = callback; + } } if (self._connection && self._connection !== Meteor.server) { @@ -403,7 +409,12 @@ _.each(["insert", "update", "remove"], function (name) { // and propagate any exception. args.push(wrappedCallback); try { - self._collection[name].apply(self._collection, args); + var queryRet = self._collection[name].apply(self._collection, args); + // On updates and removes, return whatever the collection returned; on + // inserts, always return the id that we generated. If the user provided + // a callback, then we expect queryRet to be undefined. + if (name !== "insert") + ret = queryRet; } catch (e) { if (callback) { callback(e); diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index deda9b9d48..bfaf7e4d2c 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -257,6 +257,16 @@ MongoConnection.prototype._refresh = function (collectionName, selector) { } }; +var numberAffectedCallback = function (callback) { + return Meteor.bindEnvironment(function (err, numberAffected) { + callback && callback(err, ! err && { + numberAffected: numberAffected + }); + }, function (err) { + Meteor._debug("Error in Mongo write:", err.stack); + }); +}; + MongoConnection.prototype._remove = function (collection_name, selector, callback) { var self = this; @@ -279,7 +289,7 @@ MongoConnection.prototype._remove = function (collection_name, selector, try { var collection = self._getCollection(collection_name); collection.remove(replaceTypes(selector, replaceMeteorAtomWithMongo), - {safe: true}, callback); + {safe: true}, numberAffectedCallback(callback)); } catch (e) { write.committed(); throw e; @@ -327,7 +337,7 @@ MongoConnection.prototype._update = function (collection_name, selector, mod, if (options.multi) mongoOpts.multi = true; collection.update(replaceTypes(selector, replaceMeteorAtomWithMongo), replaceTypes(mod, replaceMeteorAtomWithMongo), - mongoOpts, callback); + mongoOpts, numberAffectedCallback(callback)); } catch (e) { write.committed(); throw e; diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 64a23991ee..9e96ed8f71 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -801,6 +801,39 @@ testAsyncMulti('mongo-livedata - document with a custom type, ' + idGeneration, ]); if (Meteor.isServer) { + Tinytest.addAsync("mongo-livedata - update return values, " + idGeneration, function (test, onComplete) { + var run = test.runId(); + var coll = new Meteor.Collection("livedata_update_result_"+run, collectionOptions); + + coll.insert({ foo: "bar" }); + coll.insert({ foo: "baz" }); + test.equal(coll.update({}, { $set: { foo: "qux" } }, { multi: true }), { + numberAffected: 2 + }); + coll.update({}, { $set: { foo: "quux" } }, { multi: true }, function (err, result) { + test.isFalse(err); + test.equal(result, { numberAffected: 2 }); + onComplete(); + }); + }); + + Tinytest.addAsync("mongo-livedata - remove return values, " + idGeneration, function (test, onComplete) { + var run = test.runId(); + var coll = new Meteor.Collection("livedata_update_result_"+run, collectionOptions); + + coll.insert({ foo: "bar" }); + coll.insert({ foo: "baz" }); + test.equal(coll.remove({}), { numberAffected: 2 }); + coll.insert({ foo: "bar" }); + coll.insert({ foo: "baz" }); + coll.remove({}, function (err, result) { + test.isFalse(err); + test.equal(result, { numberAffected: 2 }); + onComplete(); + }); + }); + + Tinytest.addAsync("mongo-livedata - id-based invalidation, " + idGeneration, function (test, onComplete) { var run = test.runId(); var coll = new Meteor.Collection("livedata_invalidation_collection_"+run, collectionOptions); From 411c6cf8f4868d1b7e1762a67213b0974a3e8818 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 23 Sep 2013 14:58:21 -0700 Subject: [PATCH 074/335] Update to Node 0.10.18, bumping dev bundle version. Use caronte. --- docs/client/concepts.html | 4 ++-- meteor | 2 +- scripts/generate-dev-bundle.sh | 8 ++++++-- tools/bundler.js | 4 ++-- tools/meteor.js | 2 +- tools/server/boot.js | 2 +- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/client/concepts.html b/docs/client/concepts.html index 61d47d7c18..f0fc8f13e5 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -824,9 +824,9 @@ To get started, run $ meteor bundle myapp.tgz This command will generate a fully-contained Node.js application in the form of -a tarball. To run this application, you need to provide Node.js 0.8 and a +a tarball. To run this application, you need to provide Node.js 0.10 and a MongoDB server. (The current release of Meteor has been tested with Node -0.8.24.) You can then run the application by invoking node, specifying the HTTP +0.10.18.) You can then run the application by invoking node, specifying the HTTP port for the application to listen on, and the MongoDB endpoint. If you don't already have a MongoDB server, we can recommend our friends at [MongoHQ](http://mongohq.com). diff --git a/meteor b/meteor index 5584e6c2a4..58177c2493 100755 --- a/meteor +++ b/meteor @@ -1,6 +1,6 @@ #!/bin/bash -BUNDLE_VERSION=0.3.15 +BUNDLE_VERSION=0.3.16 # OS Check. Put here because here is where we download the precompiled # bundles that are arch specific. diff --git a/scripts/generate-dev-bundle.sh b/scripts/generate-dev-bundle.sh index 0091c5a388..ee29664782 100755 --- a/scripts/generate-dev-bundle.sh +++ b/scripts/generate-dev-bundle.sh @@ -76,7 +76,7 @@ cd node # When upgrading node versions, also update the values of MIN_NODE_VERSION at # the top of tools/meteor.js and tools/server/server.js, and the text in # docs/client/concepts.html and the README in tools/bundler.js. -git checkout v0.8.24 +git checkout v0.10.18 ./configure --prefix="$DIR" make -j4 @@ -102,7 +102,6 @@ npm install semver@1.1.0 npm install handlebars@1.0.7 npm install request@2.12.0 npm install keypress@0.1.0 -npm install http-proxy@0.10.1 # not 0.10.2, which contains a sketchy websocket change npm install underscore@1.5.1 npm install fstream@0.1.21 npm install tar@0.1.14 @@ -111,6 +110,11 @@ npm install shell-quote@0.0.1 # now at 1.3.3, which adds plenty of options to npm install byline@2.0.3 # v3 requires node 0.10 npm install source-map@0.1.26 +# Using the unreleased "caronte" rewrite of http-proxy (which is even called +# 'caronte', though this may change when this eventually hopefully becomes +# http-proxy 1.0). +npm install https://github.com/nodejitsu/node-http-proxy/tarball/94ec6fa5ce6826ca1e8974f7e99b31541aaad76a + # Using the unreleased 1.1 branch. We can probably switch to a built NPM version # when it gets released. npm install https://github.com/ariya/esprima/tarball/5044b87f94fb802d9609f1426c838874ec2007b3 diff --git a/tools/bundler.js b/tools/bundler.js index 3216ef8c2b..e64be55208 100644 --- a/tools/bundler.js +++ b/tools/bundler.js @@ -1442,8 +1442,8 @@ var writeSiteArchive = function (targets, outputPath, options) { builder.write('README', { data: new Buffer( "This is a Meteor application bundle. It has only one dependency:\n" + -"Node.js 0.8 (with the 'fibers' package). The current release of Meteor\n" + -"has been tested with Node 0.8.24. To run the application:\n" + +"Node.js 0.10 (with the 'fibers' package). The current release of Meteor\n" + +"has been tested with Node 0.10.18. To run the application:\n" + "\n" + " $ npm install fibers@1.0.1\n" + " $ export MONGO_URL='mongodb://user:password@host:port/databasename'\n" + diff --git a/tools/meteor.js b/tools/meteor.js index 290729f6b7..f5b1ad2e68 100644 --- a/tools/meteor.js +++ b/tools/meteor.js @@ -24,7 +24,7 @@ Fiber(function () { var Future = require('fibers/future'); // This code is duplicated in app/server/server.js. - var MIN_NODE_VERSION = 'v0.8.24'; + var MIN_NODE_VERSION = 'v0.10.18'; if (require('semver').lt(process.version, MIN_NODE_VERSION)) { process.stderr.write( 'Meteor requires Node ' + MIN_NODE_VERSION + ' or later.\n'); diff --git a/tools/server/boot.js b/tools/server/boot.js index 0a2fa308a2..c8931b0730 100644 --- a/tools/server/boot.js +++ b/tools/server/boot.js @@ -6,7 +6,7 @@ var _ = require('underscore'); var sourcemap_support = require('source-map-support'); // This code is duplicated in tools/server/server.js. -var MIN_NODE_VERSION = 'v0.8.24'; +var MIN_NODE_VERSION = 'v0.10.18'; if (require('semver').lt(process.version, MIN_NODE_VERSION)) { process.stderr.write( 'Meteor requires Node ' + MIN_NODE_VERSION + ' or later.\n'); From fa4509f81b34cec8cbc5975491e0961800de7e1a Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 15 Apr 2013 14:59:03 -0700 Subject: [PATCH 075/335] Use npm install --force to get around NPM cache corruption bug. (This caused test_bundler_npm to fail sporadically with Node 0.8.) --- tools/meteor_npm.js | 16 +++++++++++++--- tools/tests/test_bundler_npm.js | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tools/meteor_npm.js b/tools/meteor_npm.js index cf07689d95..d89d3e1005 100644 --- a/tools/meteor_npm.js +++ b/tools/meteor_npm.js @@ -407,9 +407,18 @@ _.extend(exports, { // We don't use npm.commands.install since we couldn't // figure out how to silence all output (specifically the // installed tree which is printed out with `console.log`) + // + // We use --force, because the NPM cache is broken! See + // https://github.com/isaacs/npm/issues/3265 Basically, switching back and + // forth between a tarball fork of version X and the real version X can + // confuse NPM. But the main reason to use tarball URLs is to get a fork of + // the latest version with some fix, so it's easy to trigger this! So + // instead, always use --force. (Even with --force, we still WRITE to the + // cache, so we can corrupt the cache for other invocations of npm... ah + // well.) var result = this._execFileSync(path.join(files.get_dev_bundle(), "bin", "npm"), - ["install", installArg], + ["install", "--force", installArg], {cwd: dir}); if (! result.success) { @@ -436,10 +445,11 @@ _.extend(exports, { this._ensureConnected(); - // `npm install`, which reads npm-shrinkwrap.json + // `npm install`, which reads npm-shrinkwrap.json. See above for why + // --force. var result = this._execFileSync(path.join(files.get_dev_bundle(), "bin", "npm"), - ["install"], {cwd: dir}); + ["install", "--force"], {cwd: dir}); if (! result.success) { diff --git a/tools/tests/test_bundler_npm.js b/tools/tests/test_bundler_npm.js index 82fe94c1dc..e4e1509bc0 100644 --- a/tools/tests/test_bundler_npm.js +++ b/tools/tests/test_bundler_npm.js @@ -172,12 +172,12 @@ assert.doesNotThrow(function () { // while bundling, verify that we don't call `npm install // name@version unnecessarily` -- calling `npm install` is enough, - // and installing each package separately coul unintentionally bump + // and installing each package separately could unintentionally bump // subdependency versions. (to intentionally bump subdependencies, // just remove all of the .npm directory) var bareExecFileSync = meteorNpm._execFileSync; meteorNpm._execFileSync = function(file, args, opts) { - if (args[0] === 'install' && args[1]) + if (args.length > 2 && args[0] === 'install' && args[1] === '--force') assert.fail("shouldn't be installing specific npm packages: " + args[1]); return bareExecFileSync(file, args, opts); }; From d1cf1bd2e389e0ab8db80de5f9e48331241fb719 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 23 Sep 2013 19:30:32 -0700 Subject: [PATCH 076/335] Make sure all packages rebuild. This isn't quite enough to rebuild NPM packages but it's a start. --- tools/packages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/packages.js b/tools/packages.js index 41832c04fd..677cb40d00 100644 --- a/tools/packages.js +++ b/tools/packages.js @@ -26,7 +26,7 @@ var sourcemap = require('source-map'); // end up as watched dependencies. (At least for now, packages only used in // target creation (eg minifiers and dev-bundle-fetcher) don't require you to // update BUILT_BY, though you will need to quit and rerun "meteor run".) -exports.BUILT_BY = 'meteor/8'; +exports.BUILT_BY = 'meteor/9'; // Like Perl's quotemeta: quotes all regexp metacharacters. See // https://github.com/substack/quotemeta/blob/master/index.js From 5f146a9d72392809488f1b04ba42f35c66a9bc63 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 23 Sep 2013 23:29:34 -0700 Subject: [PATCH 077/335] Update run.js to use caronte API. --- tools/run.js | 99 +++++++++++++++++++++------------------------------- 1 file changed, 39 insertions(+), 60 deletions(-) diff --git a/tools/run.js b/tools/run.js index e2348459e0..292007d943 100644 --- a/tools/run.js +++ b/tools/run.js @@ -108,8 +108,16 @@ var requestQueue = []; var startProxy = function (outerPort, innerPort, callback) { callback = callback || function () {}; - var httpProxy = require('http-proxy'); - var p = httpProxy.createServer(function (req, res, proxy) { + var http = require('http'); + // caronte is the code name for http-proxy 1.0 while it's under + // development. Once it's released, we may need to adjust the APIs slightly. + // (eg, the name of the event on proxy.ev will probably no longer say + // "caronte") + var caronte = require('caronte'); + + var proxy = caronte.createProxyServer({}); + + var server = http.createServer(function (req, res) { if (Status.crashing) { // sad face. send error logs. // XXX formatting! text/plain is bad @@ -126,43 +134,35 @@ var startProxy = function (outerPort, innerPort, callback) { }); res.end(); - } else if (Status.listening) { - // server is listening. things are hunky dory! - proxy.proxyRequest(req, res, { - host: '127.0.0.1', port: innerPort - }); - } else { - // Not listening yet. Queue up request. - var buffer = httpProxy.buffer(req); - requestQueue.push(function () { - proxy.proxyRequest(req, res, { - host: '127.0.0.1', port: innerPort, - buffer: buffer - }); - }); + return; } - }); - - // Proxy websocket requests using same buffering logic as for regular HTTP requests - p.on('upgrade', function(req, socket, head) { + var proxyIt = function () { + proxy.web(req, res, {target: 'http://127.0.0.1:' + innerPort}); + }; if (Status.listening) { // server is listening. things are hunky dory! - p.proxy.proxyWebSocketRequest(req, socket, head, { - host: '127.0.0.1', port: innerPort - }); + proxyIt(); } else { - // Not listening yet. Queue up request. - var buffer = httpProxy.buffer(req); - requestQueue.push(function () { - p.proxy.proxyWebSocketRequest(req, socket, head, { - host: '127.0.0.1', port: innerPort, - buffer: buffer - }); - }); + requestQueue.push(proxyIt); } }); - p.on('error', function (err) { + // Proxy websocket requests using same buffering logic as for regular HTTP + // requests + server.on('upgrade', function(req, socket, head) { + var proxyIt = function () { + proxy.ws(req, socket, head, { target: 'http://127.0.0.1:' + innerPort}); + }; + if (Status.listening) { + // server is listening. things are hunky dory! + proxyIt(); + } else { + // Not listening yet. Queue up request. + requestQueue.push(proxyIt); + } + }); + + server.on('error', function (err) { if (err.code == 'EADDRINUSE') { process.stderr.write("Can't listen on port " + outerPort + ". Perhaps another Meteor is running?\n"); @@ -177,17 +177,19 @@ var startProxy = function (outerPort, innerPort, callback) { process.exit(1); }); - // don't spin forever if the app doesn't respond. instead return an - // error immediately. This shouldn't happen much since we try to not - // send requests if the app is down. - p.proxy.on('proxyError', function (err, req, res) { + // don't crash if the app doesn't respond. instead return an error + // immediately. This shouldn't happen much since we try to not send requests + // if the app is down. + // XXX should we also handle caronte:outgoing:ws:error, for a failed + // websocket? + proxy.ee.on('caronte:outgoing:web:error', function (err, req, res) { res.writeHead(503, { 'Content-Type': 'text/plain' }); res.end('Unexpected error.'); }); - p.listen(outerPort, callback); + server.listen(outerPort, callback); }; var saveLog = function (msg) { @@ -402,29 +404,6 @@ exports.run = function (context, options) { ("mongodb://127.0.0.1:" + mongoPort + "/meteor"); var firstRun = true; - // node-http-proxy doesn't properly handle errors if it has a problem writing - // to the proxy target. While we try to not proxy requests when we don't think - // the target is listening, there are race conditions here, and in any case - // those attempts don't take effect for pre-existing websocket connections. - // Error handling in node-http-proxy is really convoluted and will change with - // their ongoing Node 0.10.x compatible rewrite, so rather than trying to - // debug and send pull request now, we'll wait for them to finish their - // rewrite. In the meantime, ignore two common exceptions that we sometimes - // see instead of crashing. - // - // See https://github.com/meteor/meteor/issues/513 - // - // That bug is about "meteor deploy"s use of http-proxy, but it also affects - // our use here; see - // https://groups.google.com/d/msg/meteor-core/JgbnfKEa5lA/FJHZtJftfSsJ - // - // XXX remove this once we've upgraded and fixed http-proxy - process.on('uncaughtException', function (e) { - if (e && (e.errno === 'EPIPE' || e.message === "This socket is closed.")) - return; - throw e; - }); - var serverHandle; var watcher; From dd7172ab7fc7141b68b766cf5c600efb0dde2739 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 24 Sep 2013 00:35:15 -0700 Subject: [PATCH 078/335] Use an http agent (this was default in old http-proxy but not in caronte). --- tools/run.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/run.js b/tools/run.js index 292007d943..311bcbbb40 100644 --- a/tools/run.js +++ b/tools/run.js @@ -115,7 +115,11 @@ var startProxy = function (outerPort, innerPort, callback) { // "caronte") var caronte = require('caronte'); - var proxy = caronte.createProxyServer({}); + var proxy = caronte.createProxyServer({ + // agent is required to handle keep-alive, and caronte is a little buggy + // without it: https://github.com/nodejitsu/node-http-proxy/pull/488 + agent: new http.Agent({maxSockets: 100}) + }); var server = http.createServer(function (req, res) { if (Status.crashing) { From ca1fb6788abbc03cf5f3f5265c71a716c5a248c0 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 24 Sep 2013 17:12:39 -0700 Subject: [PATCH 079/335] Track the node version used when installing NPM modules. This way, when the dev bundle version changes the Node version they will get reinstalled. (You still need to bump BUILT_BY to get the modules into unipackages too.) --- tools/meteor_npm.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tools/meteor_npm.js b/tools/meteor_npm.js index d89d3e1005..780958fd28 100644 --- a/tools/meteor_npm.js +++ b/tools/meteor_npm.js @@ -186,6 +186,26 @@ _.extend(exports, { throw new Error( "Corrupted .npm directory -- can't find npm-shrinkwrap.json in " + packageNpmDir); + // We need to rebuild all node modules when the Node version changes, in + // case there are some binary ones. Technically this is racey, but it + // shouldn't fail very often. + if (fs.existsSync(path.join(packageNpmDir, 'node_modules'))) { + var oldNodeVersion; + try { + oldNodeVersion = fs.readFileSync( + path.join(packageNpmDir, 'node_modules', '.node_version')); + } catch (e) { + if (e.code !== 'ENOENT') + throw e; + // Use the Node version from the last release where we didn't drop this + // file. + oldNodeVersion = 'v0.8.24'; + } + + if (oldNodeVersion !== process.version) + files.rm_recursive(path.join(packageNpmDir, 'node_modules')); + } + var installedDependencies = self._installedDependencies(packageNpmDir); // If we already have the right things installed, life is good. @@ -276,6 +296,7 @@ _.extend(exports, { fs.unlinkSync(path.join(newPackageNpmDir, 'package.json')); self._createReadme(newPackageNpmDir); + self._createNodeVersion(newPackageNpmDir); files.renameDirAlmostAtomically(newPackageNpmDir, packageNpmDir); }, @@ -292,6 +313,12 @@ _.extend(exports, { ); }, + _createNodeVersion: function(newPackageNpmDir) { + fs.writeFileSync( + path.join(newPackageNpmDir, 'node_modules', '.node_version'), + process.version); + }, + // Returns object with keys 'stdout', 'stderr', and 'success' (true // for clean exit with exit code 0, else false) _execFileSync: function(file, args, opts) { From 41247305db798cab408f85b57e88a1efd254fefa Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Tue, 24 Sep 2013 17:59:59 -0700 Subject: [PATCH 080/335] version 0 of optimistic upsert simulation --- packages/mongo-livedata/collection.js | 14 ++++ packages/mongo-livedata/mongo_driver.js | 80 ++++++++++++++++++- .../mongo-livedata/mongo_livedata_tests.js | 36 ++++++++- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 01f7401cfb..ffb33545d3 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -374,6 +374,20 @@ _.each(["insert", "update", "remove"], function (name) { } } else { args[0] = Meteor.Collection._rewriteSelector(args[0]); + + if (name === "update") { + var options = args[2]; + if (options && options.upsert) { + // set `insertedId` if absent. `insertedId` is a Meteor extension. + if (options.insertedId) { + if (!(typeof options.insertedId === 'string' + || options.insertedId instanceof Meteor.Collection.ObjectID)) + throw new Error("insertedId must be string or ObjectID"); + } else { + options.insertedId = self._makeNewID(); + } + } + } } var wrappedCallback; diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index bfaf7e4d2c..0e2a5ce9d6 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -335,15 +335,89 @@ MongoConnection.prototype._update = function (collection_name, selector, mod, // explictly enumerate options that minimongo supports if (options.upsert) mongoOpts.upsert = true; if (options.multi) mongoOpts.multi = true; - collection.update(replaceTypes(selector, replaceMeteorAtomWithMongo), - replaceTypes(mod, replaceMeteorAtomWithMongo), - mongoOpts, numberAffectedCallback(callback)); + + var mongoSelector = replaceTypes(selector, replaceMeteorAtomWithMongo); + var mongoMod = replaceTypes(mod, replaceMeteorAtomWithMongo); + + var isModify = isModificationMod(mongoMod); + + if (options.upsert && + (isModify ? (! mongoSelector._id) : (! mongoMod._id)) && + options.insertedId) { + mongoOpts.insertedId = options.insertedId; + simulateUpsertWithInsertedId(collection, mongoSelector, mongoMod, + isModify, mongoOpts, callback); + } else { + collection.update(mongoSelector, mongoMod, mongoOpts, + numberAffectedCallback(callback)); + } } catch (e) { write.committed(); throw e; } }; +var isModificationMod = function (mod) { + for (var k in mod) + if (k.substr(0, 1) === '$') + return true; + return false; +}; + +var simulateUpsertWithInsertedId = function (collection, selector, mod, + isModify, options, callback) { + var insertedId = options.insertedId; // must exist + + var mongoOpts = _.extend({}, options); + delete mongoOpts.insertedId; + delete mongoOpts.upsert; + + var doUpdate = function () { + mongoOpts.upsert = false; + collection.update(selector, mod, mongoOpts, + numberAffectedCallback(function (err, result) { + if (err) { + callback(err); + } else if (result.numberAffected) { + callback(null, result); + } else { + doConditionalInsert(); + } + })); + }; + + var doConditionalInsert = function () { + mongoOpts.upsert = true; + var replacementWithId = _.extend( + replaceTypes({_id: insertedId}, replaceMeteorAtomWithMongo), + mod); + collection.update(selector, replacementWithId, mongoOpts, + numberAffectedCallback(function (err, result) { + if (err) { + // XXX figure out if this is a + // "cannot change _id of document" error, and + // if so, try doUpdate() again, up to 3 times. + // Otherwise, pass err to callback. + Meteor._debug(err); + } else { + callback(null, _.extend(result, + { insertedId: insertedId })); + } + })); + }; + + if (isModify) { + // XXX TODO + } else { + doUpdate(); + } +}; + +var modifyDocument = function (doc, mod) { + // XXX use LocalCollection._modify + return mod; +}; + _.each(["insert", "update", "remove"], function (method) { MongoConnection.prototype[method] = function (/* arguments */) { var self = this; diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 9e96ed8f71..06c4a9439e 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -901,8 +901,42 @@ if (Meteor.isServer) { _.each(handlesToStop, function (h) {h.stop();}); onComplete(); }); -} + Tinytest.addAsync("mongo-livedata - upsert, " + idGeneration, function (test, onComplete) { + var run = test.runId(); + var coll = new Meteor.Collection("livedata_upsert_collection_"+run, collectionOptions); + + var result1 = coll.update({foo: 'bar'}, {foo: 'bar'}, {upsert: true}); + test.equal(result1.numberAffected, 1); + test.isTrue(result1.insertedId); + test.equal(coll.find().fetch(), [{foo: 'bar', _id: result1.insertedId}]); + + var result2 = coll.update({foo: 'bar'}, {foo: 'baz'}, {upsert: true}); + test.equal(result2.numberAffected, 1); + test.isFalse(result2.insertedId); + test.equal(coll.find().fetch(), [{foo: 'baz', _id: result1.insertedId}]); + + coll.remove({}); + + // Test values that require transformation to go into Mongo: + + var t1 = new Meteor.Collection.ObjectID(); + var t2 = new Meteor.Collection.ObjectID(); + var result3 = coll.update({foo: t1}, {foo: t1}, {upsert: true}); + test.equal(result3.numberAffected, 1); + test.isTrue(result3.insertedId); + test.equal(coll.find().fetch(), [{foo: t1, _id: result3.insertedId}]); + + var result4 = coll.update({foo: t1}, {foo: t2}, {upsert: true}); + test.equal(result2.numberAffected, 1); + test.isFalse(result2.insertedId); + test.equal(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); + + + onComplete(); + }); + +} // end Meteor.isServer }); // end idGeneration parametrization From 220676600e904507910bde0c2cd9ddc286b74450 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Tue, 24 Sep 2013 19:05:32 -0700 Subject: [PATCH 081/335] upsert supports modify and multi doesn't actually retry 3 times yet (needs to determine when to) --- packages/minimongo/modify.js | 4 +- packages/mongo-livedata/mongo_driver.js | 77 ++++++++++++------- .../mongo-livedata/mongo_livedata_tests.js | 45 +++++++++++ 3 files changed, 97 insertions(+), 29 deletions(-) diff --git a/packages/minimongo/modify.js b/packages/minimongo/modify.js index e699a262ff..8413899eb1 100644 --- a/packages/minimongo/modify.js +++ b/packages/minimongo/modify.js @@ -5,7 +5,7 @@ // // XXX atomicity: if one modification fails, do we roll back the whole // change? -LocalCollection._modify = function (doc, mod) { +LocalCollection._modify = function (doc, mod, onInsert) { var is_modifier = false; for (var k in mod) { // IE7 doesn't support indexing into strings (eg, k[0]), so use substr. @@ -35,6 +35,8 @@ LocalCollection._modify = function (doc, mod) { for (var op in mod) { var mod_func = LocalCollection._modifiers[op]; + if (onInsert && op === '$setOnInsert') + mod_func = LocalCollection._modifiers['$set']; if (!mod_func) throw Error("Invalid modifier specified " + op); for (var keypath in mod[op]) { diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 0e2a5ce9d6..3b91586d15 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -364,41 +364,71 @@ var isModificationMod = function (mod) { return false; }; +var NUM_OPTIMISTIC_TRIES = 3; + var simulateUpsertWithInsertedId = function (collection, selector, mod, isModify, options, callback) { var insertedId = options.insertedId; // must exist - var mongoOpts = _.extend({}, options); - delete mongoOpts.insertedId; - delete mongoOpts.upsert; + var mongoOptsForUpdate = _.extend({}, options); + delete mongoOptsForUpdate.insertedId; + delete mongoOptsForUpdate.upsert; + + var tries = NUM_OPTIMISTIC_TRIES; var doUpdate = function () { - mongoOpts.upsert = false; - collection.update(selector, mod, mongoOpts, - numberAffectedCallback(function (err, result) { - if (err) { - callback(err); - } else if (result.numberAffected) { - callback(null, result); - } else { - doConditionalInsert(); - } - })); + tries--; + if (! tries) { + callback(new Error("Upsert failed after " + NUM_OPTIMISTIC_TRIES + " tries.")); + } else { + collection.update(selector, mod, mongoOptsForUpdate, + numberAffectedCallback(function (err, result) { + if (err) { + callback(err); + } else if (result.numberAffected) { + callback(null, result); + } else { + doConditionalInsert(); + } + })); + } }; + var newDoc; + // Run this code up front so that it fails fast if someone uses + // a Mongo update operator we don't support. + if (isModify) { + var selectorDoc = {}; + for (var k in selector) + if (k.substr(0, 1) !== '$') + selectorDoc[k] = selector[k]; + // We've already run replaceTypes/replaceMeteorAtomWithMongo on + // selector and mod. We assume it doesn't matter, as far as + // the behavior of modifiers is concerned, whether `_modify` + // is run on EJSON or on mongo-converted EJSON. + LocalCollection._modify(selectorDoc, mod, true); + newDoc = selectorDoc; + } else { + newDoc = mod; + } + + var mongoOptsForInsert = _.extend({}, options); + delete mongoOptsForUpdate.insertedId; + mongoOptsForInsert.upsert = true; + delete mongoOptsForInsert.multi; + var doConditionalInsert = function () { - mongoOpts.upsert = true; var replacementWithId = _.extend( replaceTypes({_id: insertedId}, replaceMeteorAtomWithMongo), - mod); - collection.update(selector, replacementWithId, mongoOpts, + newDoc); + collection.update(selector, replacementWithId, mongoOptsForInsert, numberAffectedCallback(function (err, result) { if (err) { // XXX figure out if this is a // "cannot change _id of document" error, and // if so, try doUpdate() again, up to 3 times. - // Otherwise, pass err to callback. Meteor._debug(err); + callback(err); } else { callback(null, _.extend(result, { insertedId: insertedId })); @@ -406,16 +436,7 @@ var simulateUpsertWithInsertedId = function (collection, selector, mod, })); }; - if (isModify) { - // XXX TODO - } else { - doUpdate(); - } -}; - -var modifyDocument = function (doc, mod) { - // XXX use LocalCollection._modify - return mod; + doUpdate(); }; _.each(["insert", "update", "remove"], function (method) { diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 06c4a9439e..61c4d2516c 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -932,6 +932,51 @@ if (Meteor.isServer) { test.isFalse(result2.insertedId); test.equal(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); + coll.remove({}); + + // Test modification + + var result5 = coll.update({name: 'David'}, {$set: {foo: 1}}, {upsert: true}); + test.equal(result5.numberAffected, 1); + test.isTrue(result5.insertedId); + var davidId = result5.insertedId; + test.equal(coll.find().fetch(), [{name: 'David', foo: 1, _id: davidId}]); + + test.throws(function () { + // test that bad modifier fails fast + coll.update({name: 'David'}, {$blah: {foo: 2}}, {upsert: true}); + }); + + + var result6 = coll.update({name: 'David'}, {$set: {foo: 2}}, {upsert: true}); + test.equal(result6.numberAffected, 1); + test.isFalse(result6.insertedId); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, + _id: result5.insertedId}]); + + var emilyId = coll.insert({name: 'Emily', foo: 2}); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, _id: davidId}, + {name: 'Emily', foo: 2, _id: emilyId}]); + + // multi update by upsert + var result7 = coll.update({foo: 2}, + {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, + {upsert: true, multi: true}); + test.equal(result7.numberAffected, 2); + test.isFalse(result7.insertedId); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}]); + + // insert by multi upsert + var result8 = coll.update({foo: 3}, + {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, + {upsert: true, multi: true}); + test.equal(result8.numberAffected, 1); + test.isTrue(result8.insertedId); + var fredId = result8.insertedId; + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, + {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); onComplete(); }); From 0e988fcf5a1acebcdbdca347cd721e5a4a542743 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Tue, 24 Sep 2013 19:13:01 -0700 Subject: [PATCH 082/335] strategy comment --- packages/mongo-livedata/mongo_driver.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 3b91586d15..8171ae0d17 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -376,6 +376,20 @@ var simulateUpsertWithInsertedId = function (collection, selector, mod, var tries = NUM_OPTIMISTIC_TRIES; + // STRATEGY: First try doing a plain update. If it affected 0 documents, + // then without affecting the database, we know we should probably do an + // insert. We then do a *conditional* insert that will fail in the case + // of a race condition. This conditional insert is actually an + // upsert-replace with an _id, which will never successfully update an + // existing document. If this upsert fails with an error saying it + // couldn't change an existing _id, then we know an intervening write has + // caused the query to match something. We go back to step one and repeat. + // Like all "optimistic write" schemes, we rely on the fact that it's + // unlikely our writes will continue to be interfered with under normal + // circumstances (though sufficiently heavy contention with writers + // disagreeing on the existence of an object will cause writes to fail + // in theory). + var doUpdate = function () { tries--; if (! tries) { From 6c1f92e89156e529b2da3555d65eb9094ff4e9d9 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 24 Sep 2013 23:31:47 -0700 Subject: [PATCH 083/335] Might as well use the version that just got released (0.10.19). --- docs/client/concepts.html | 2 +- scripts/generate-dev-bundle.sh | 4 ++-- tools/bundler.js | 2 +- tools/meteor.js | 2 +- tools/server/boot.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/client/concepts.html b/docs/client/concepts.html index f0fc8f13e5..67e13a9852 100644 --- a/docs/client/concepts.html +++ b/docs/client/concepts.html @@ -826,7 +826,7 @@ To get started, run This command will generate a fully-contained Node.js application in the form of a tarball. To run this application, you need to provide Node.js 0.10 and a MongoDB server. (The current release of Meteor has been tested with Node -0.10.18.) You can then run the application by invoking node, specifying the HTTP +0.10.19.) You can then run the application by invoking node, specifying the HTTP port for the application to listen on, and the MongoDB endpoint. If you don't already have a MongoDB server, we can recommend our friends at [MongoHQ](http://mongohq.com). diff --git a/scripts/generate-dev-bundle.sh b/scripts/generate-dev-bundle.sh index ee29664782..86bea06977 100755 --- a/scripts/generate-dev-bundle.sh +++ b/scripts/generate-dev-bundle.sh @@ -74,9 +74,9 @@ cd build git clone git://github.com/joyent/node.git cd node # When upgrading node versions, also update the values of MIN_NODE_VERSION at -# the top of tools/meteor.js and tools/server/server.js, and the text in +# the top of tools/meteor.js and tools/server/boot.js, and the text in # docs/client/concepts.html and the README in tools/bundler.js. -git checkout v0.10.18 +git checkout v0.10.19 ./configure --prefix="$DIR" make -j4 diff --git a/tools/bundler.js b/tools/bundler.js index e64be55208..22527a7cca 100644 --- a/tools/bundler.js +++ b/tools/bundler.js @@ -1443,7 +1443,7 @@ var writeSiteArchive = function (targets, outputPath, options) { builder.write('README', { data: new Buffer( "This is a Meteor application bundle. It has only one dependency:\n" + "Node.js 0.10 (with the 'fibers' package). The current release of Meteor\n" + -"has been tested with Node 0.10.18. To run the application:\n" + +"has been tested with Node 0.10.19. To run the application:\n" + "\n" + " $ npm install fibers@1.0.1\n" + " $ export MONGO_URL='mongodb://user:password@host:port/databasename'\n" + diff --git a/tools/meteor.js b/tools/meteor.js index f5b1ad2e68..db2d770aa7 100644 --- a/tools/meteor.js +++ b/tools/meteor.js @@ -24,7 +24,7 @@ Fiber(function () { var Future = require('fibers/future'); // This code is duplicated in app/server/server.js. - var MIN_NODE_VERSION = 'v0.10.18'; + var MIN_NODE_VERSION = 'v0.10.19'; if (require('semver').lt(process.version, MIN_NODE_VERSION)) { process.stderr.write( 'Meteor requires Node ' + MIN_NODE_VERSION + ' or later.\n'); diff --git a/tools/server/boot.js b/tools/server/boot.js index c8931b0730..091aee0ff1 100644 --- a/tools/server/boot.js +++ b/tools/server/boot.js @@ -6,7 +6,7 @@ var _ = require('underscore'); var sourcemap_support = require('source-map-support'); // This code is duplicated in tools/server/server.js. -var MIN_NODE_VERSION = 'v0.10.18'; +var MIN_NODE_VERSION = 'v0.10.19'; if (require('semver').lt(process.version, MIN_NODE_VERSION)) { process.stderr.write( 'Meteor requires Node ' + MIN_NODE_VERSION + ' or later.\n'); From 25aae35422c668e14bb02faf069cf52f3b0f500e Mon Sep 17 00:00:00 2001 From: David Glasser Date: Wed, 25 Sep 2013 01:57:50 -0700 Subject: [PATCH 084/335] Fix .node_version check. run-tools-tests passes. --- tools/meteor_npm.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/meteor_npm.js b/tools/meteor_npm.js index 780958fd28..67fb840d33 100644 --- a/tools/meteor_npm.js +++ b/tools/meteor_npm.js @@ -193,7 +193,7 @@ _.extend(exports, { var oldNodeVersion; try { oldNodeVersion = fs.readFileSync( - path.join(packageNpmDir, 'node_modules', '.node_version')); + path.join(packageNpmDir, 'node_modules', '.node_version'), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; From ad705d5ee08c1a72f4b23b90b1dcf638504a0229 Mon Sep 17 00:00:00 2001 From: Andrew Mao Date: Tue, 24 Sep 2013 22:15:25 -0400 Subject: [PATCH 085/335] Update dependencies for madewith package; fixes #1448 --- packages/madewith/package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/madewith/package.js b/packages/madewith/package.js index b67b92bf80..79cb20411a 100644 --- a/packages/madewith/package.js +++ b/packages/madewith/package.js @@ -3,7 +3,7 @@ Package.describe({ }); Package.on_use(function (api) { - api.use(['livedata', 'underscore', 'spark', 'templating'], 'client'); + api.use(['livedata', 'mongo-livedata', 'underscore', 'spark', 'templating'], 'client'); api.add_files([ 'madewith.css', From a3d539ed0bab7b8fa0f72cb7daf6467ba0aec17c Mon Sep 17 00:00:00 2001 From: Matt DeBergalis Date: Wed, 25 Sep 2013 11:39:24 -0700 Subject: [PATCH 086/335] Disallow words added after game clock expires. Fixes #541. --- examples/wordplay/model.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/wordplay/model.js b/examples/wordplay/model.js index 3851c2639f..5c609e4376 100644 --- a/examples/wordplay/model.js +++ b/examples/wordplay/model.js @@ -102,9 +102,11 @@ Meteor.methods({ var word = Words.findOne(word_id); var game = Games.findOne(word.game_id); - // client and server can both check: must be at least three chars - // long, not already used, and possible to make on the board. - if (word.length < 3 + // client and server can both check that the game has time remaining, and + // that the word is at least three chars, isn't already used, and is + // possible to make on the board. + if (game.clock === 0 + || word.length < 3 || Words.find({game_id: word.game_id, word: word.word}).count() > 1 || paths_for_word(game.board, word.word).length === 0) { Words.update(word._id, {$set: {score: 0, state: 'bad'}}); From 569e92201a939ff838255a7fb09c16d0dabae861 Mon Sep 17 00:00:00 2001 From: Avital Oliver Date: Wed, 25 Sep 2013 11:57:49 -0700 Subject: [PATCH 087/335] Add XXX to reload package informed by #657 --- packages/reload/reload.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/reload/reload.js b/packages/reload/reload.js index 2c2f412e7e..6aae7f191b 100644 --- a/packages/reload/reload.js +++ b/packages/reload/reload.js @@ -26,6 +26,11 @@ * the client's session to render properly. */ +// XXX when making this API public, also expose a flag for the app +// developer to know whether a hot code push is happening. This is +// useful for apps using `window.onbeforeunload`. See +// https://github.com/meteor/meteor/pull/657 + var KEY_NAME = 'Meteor_Reload'; // after how long should we consider this no longer an automatic // reload, but a fresh restart. This only happens if a reload is From 5889adea901ab017748ea6a0bfdb7437e0db0a7a Mon Sep 17 00:00:00 2001 From: Matt DeBergalis Date: Wed, 25 Sep 2013 12:13:03 -0700 Subject: [PATCH 088/335] Various wordplay fixes: * Don't add blank lines to the dictionary. * Fix broken word length check in `score_word`. * Prevent event handler from submitting blank words. --- examples/wordplay/client/wordplay.js | 5 ++--- examples/wordplay/model.js | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/wordplay/client/wordplay.js b/examples/wordplay/client/wordplay.js index f49212923b..acff55aaeb 100644 --- a/examples/wordplay/client/wordplay.js +++ b/examples/wordplay/client/wordplay.js @@ -132,9 +132,8 @@ Template.scratchpad.events({ 'click button, keyup input': function (evt) { var textbox = $('#scratchpad input'); // if we clicked the button or hit enter - if (evt.type === "click" || - (evt.type === "keyup" && evt.which === 13)) { - + if ((evt.type === "click" || (evt.type === "keyup" && evt.which === 13)) + && textbox.val()) { var word_id = Words.insert({player_id: Session.get('player_id'), game_id: game() && game()._id, word: textbox.val().toUpperCase(), diff --git a/examples/wordplay/model.js b/examples/wordplay/model.js index 5c609e4376..416c4db122 100644 --- a/examples/wordplay/model.js +++ b/examples/wordplay/model.js @@ -106,7 +106,8 @@ Meteor.methods({ // that the word is at least three chars, isn't already used, and is // possible to make on the board. if (game.clock === 0 - || word.length < 3 + || !word.word + || word.word.length < 3 || Words.find({game_id: word.game_id, word: word.word}).count() > 1 || paths_for_word(game.board, word.word).length === 0) { Words.update(word._id, {$set: {score: 0, state: 'bad'}}); @@ -129,8 +130,8 @@ Meteor.methods({ if (Meteor.isServer) { DICTIONARY = {}; _.each(Assets.getText("enable2k.txt").split("\n"), function (line) { - // Skip comment lines - if (line.indexOf("//") !== 0) { + // Skip blanks and comment lines + if (line && line.indexOf("//") !== 0) { DICTIONARY[line] = true; } }); From 18050d9471236d10d48e60c767297c55fa6aeb84 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 25 Sep 2013 15:44:12 -0700 Subject: [PATCH 089/335] Return { numberAffected: x } from updates and removes. --- packages/minimongo/minimongo.js | 10 ++++++---- packages/minimongo/minimongo_tests.js | 8 ++++---- packages/mongo-livedata/mongo_livedata_tests.js | 8 ++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 1ec5ed6789..1735dd646c 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -536,11 +536,12 @@ LocalCollection.prototype.remove = function (selector, callback) { LocalCollection._recomputeResults(query); }); self._observeQueue.drain(); + var result = { numberAffected: remove.length }; if (callback) Meteor.defer(function () { - callback(null, remove.length); + callback(null, result); }); - return remove.length; + return result; }; // XXX atomicity: if multi is true, and one modification fails, do @@ -591,11 +592,12 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { qidToOriginalResults[qid]); }); self._observeQueue.drain(); + var result = { numberAffected: updateCount }; if (callback) Meteor.defer(function () { - callback(null, updateCount); + callback(null, result); }); - return updateCount; + return result; }; LocalCollection.prototype._modifyAndNotify = function ( diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 343e7f198c..a996207b0c 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -89,7 +89,7 @@ Tinytest.add("minimongo - basics", function (test) { test.length(c.find({type: "cryptographer"}).fetch(), 2); count = c.update({name: "snookums"}, {$set: {type: "cryptographer"}}); - test.equal(count, 1); + test.equal(count, { numberAffected: 1 }); test.equal(c.find().count(), 4); test.equal(c.find({type: "kitten"}).count(), 1); test.equal(c.find({type: "cryptographer"}).count(), 3); @@ -105,11 +105,11 @@ Tinytest.add("minimongo - basics", function (test) { c.remove({_id: false}); c.remove({_id: undefined}); count = c.remove(); - test.equal(count, 0); + test.equal(count, { numberAffected: 0 }); test.equal(c.find().count(), 4); count = c.remove({}); - test.equal(count, 4); + test.equal(count, { numberAffected: 4 }); test.equal(c.find().count(), 0); c.insert({_id: 1, name: "strawberry", tags: ["fruit", "red", "squishy"]}); @@ -2016,7 +2016,7 @@ Tinytest.add("minimongo - saveOriginals", function (test) { c.update('bar', {$set: {k: 7}}); // update same doc twice // Verify returned count is correct - test.equal(count, 2); + test.equal(count, { numberAffected: 2 }); // Verify the originals. var originals = c.retrieveOriginals(); diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index fe80f98e99..8f529066cb 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -203,12 +203,12 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on expectObserve('', function () { var count = coll.update({run: run, x: -1}, {$inc: {x: 2}}, {multi: true}); - test.equal(count, 0); + test.equal(count, { numberAffected: 0 }); }); expectObserve('c(3,0,1)c(6,1,4)', function () { var count = coll.update({run: run}, {$inc: {x: 2}}, {multi: true}); - test.equal(count, 2); + test.equal(count, { numberAffected: 2 }); test.equal(_.pluck(coll.find({run: run}, {sort: {x: -1}}).fetch(), "x"), [6, 3]); }); @@ -222,7 +222,7 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on expectObserve('r(13,1)', function () { var count = coll.remove({run: run, x: {$gt: 10}}); - test.equal(count, 1); + test.equal(count, { numberAffected: 1 }); test.equal(coll.find({run: run}).count(), 1); }); @@ -233,7 +233,7 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on expectObserve('', function () { var count = coll.remove({run: run}); - test.equal(count, 0); + test.equal(count, { numberAffected: 0 }); test.equal(coll.find({run: run}).count(), 0); }); From 2413a8d3ed5ffb10e7deb20c3e6292b96e4a2a0e Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 24 Sep 2013 11:35:12 -0700 Subject: [PATCH 090/335] Use cryptographic PRNGs when available. This means node's crypto.randomBytes on the server, and window.crypto.getRandomValues on the client. If node's crypto.randomBytes throws an exception, we fall back to crypto.pseudoRandomBytes. If window.crypto.getRandomValues isn't supported by the browser, we fall back to the alea generator that we had been using previously. --- docs/client/packages/random.html | 12 +-- packages/random/random.js | 144 ++++++++++++++++--------- packages/random/random_tests.js | 14 +++ packages/spark/spark_tests.js | 2 +- packages/test-helpers/seeded_random.js | 2 +- 5 files changed, 113 insertions(+), 61 deletions(-) diff --git a/docs/client/packages/random.html b/docs/client/packages/random.html index 8f0490613d..72c7ce9f51 100644 --- a/docs/client/packages/random.html +++ b/docs/client/packages/random.html @@ -3,8 +3,11 @@ ## `random` The `random` package provides several functions for generating random -numbers. It uses a Meteor-provided random number generator that does not depend -on the browser's facilities. +numbers. It uses a cryptographically strong pseudorandom number generator when +possible, but falls back to a weaker random number generator when +cryptographically strong randomness is not available (on older browsers or on +servers that don't have enough entropy to seed the cryptographically strong +generator).
{{#dtdd "Random.id()"}} @@ -25,10 +28,5 @@ Returns a random string of `n` hexadecimal digits. {{/dtdd}}
-{{#note}} -In the current implementation, random values do not come from a -cryptographically strong pseudorandom number generator. Future releases will -improve this, particularly on the server. -{{/note}} {{/better_markdown}} diff --git a/packages/random/random.js b/packages/random/random.js index 5faddb9d4c..59e94683e8 100644 --- a/packages/random/random.js +++ b/packages/random/random.js @@ -1,3 +1,15 @@ +// We use cryptographically strong PRNGs (crypto.getRandomBytes() on the server, +// window.crypto.getRandomValues() in the browser) when available. If these +// PRNGs fail, we fall back to the Alea PRNG, which is not cryptographically +// strong, and we seed it with various sources such as the date, Math.random, +// and window size on the client. When using crypto.getRandomValues(), our +// primitive is hexString(), from which we construct fraction(). When using +// window.crypto.getRandomValues() or alea, the primitive is fraction and we use +// that to construct hex string. + +if (Meteor.isServer) + var nodeCrypto = Npm.require('crypto'); + // see http://baagoe.org/en/wiki/Better_random_numbers_for_javascript // for a full discussion and Alea implementation. var Alea = function () { @@ -75,52 +87,79 @@ var Alea = function () { var UNMISTAKABLE_CHARS = "23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz"; -var create = function (/* arguments */) { - - var random = Alea.apply(null, arguments); - - var self = {}; - - var bind = function (fn) { - return _.bind(fn, self); - }; - - return _.extend(self, { - _Alea: Alea, - - create: create, - - fraction: random, - - choice: bind(function (arrayOrString) { - var index = Math.floor(this.fraction() * arrayOrString.length); - if (typeof arrayOrString === "string") - return arrayOrString.substr(index, 1); - else - return arrayOrString[index]; - }), - - id: bind(function() { - var digits = []; - // Length of 17 preserves around 96 bits of entropy, which is the - // amount of state in our PRNG - for (var i = 0; i < 17; i++) { - digits[i] = this.choice(UNMISTAKABLE_CHARS); - } - return digits.join(""); - }), - - hexString: bind(function (digits) { - var hexDigits = []; - for (var i = 0; i < digits; ++i) { - hexDigits.push(this.choice("0123456789abcdef")); - } - return hexDigits.join(''); - }) - }); +// If seeds are provided, then the alea PRNG will be used, since cryptographic +// PRNGs (Node crypto and window.crypto.getRandomValues) don't allow us to +// specify seeds. The caller is responsible for making sure to provide a seed +// for alea if a csprng is not available. +var RandomGenerator = function (seedArray) { + var self = this; + if (seedArray !== undefined) + self.alea = Alea.apply(null, seedArray); + self._Alea = Alea; }; -// instantiate RNG. Heuristically collect entropy from various sources +RandomGenerator.prototype.fraction = function () { + var self = this; + if (self.alea) { + return self.alea(); + } else if (nodeCrypto) { + var numerator = parseInt(self.hexString(8), 16); + return numerator * 2.3283064365386963e-10; // 2^-32 + } else if (typeof window !== "undefined" && window.crypto && + window.crypto.getRandomValues) { + var array = new Uint32Array(1); + window.crypto.getRandomValues(array); + return array[0] * 2.3283064365386963e-10; // 2^-32 + } +}; + +RandomGenerator.prototype.hexString = function (digits) { + var self = this; + if (nodeCrypto && ! self.alea) { + var numBytes = Math.ceil(digits / 2); + var bytes; + // Try to get cryptographically strong randomness. Fall back to + // non-cryptographically strong if not available. + try { + bytes = nodeCrypto.randomBytes(numBytes); + } catch (e) { + // XXX should re-throw any error except insufficient entropy + bytes = nodeCrypto.pseudoRandomBytes(numBytes); + } + var result = bytes.toString("hex"); + // If the number of digits is odd, we'll have generated an extra 4 bits + // of randomness, so we need to trim the last digit. + return result.substring(0, digits); + } else { + var hexDigits = []; + for (var i = 0; i < digits; ++i) { + hexDigits.push(self.choice("0123456789abcdef")); + } + return hexDigits.join(''); + } +}; + +RandomGenerator.prototype.id = function () { + var digits = []; + var self = this; + // Length of 17 preserves around 96 bits of entropy, which is the + // amount of state in the Alea PRNG. + for (var i = 0; i < 17; i++) { + digits[i] = self.choice(UNMISTAKABLE_CHARS); + } + return digits.join(""); +}; + +RandomGenerator.prototype.choice = function (arrayOrString) { + var index = Math.floor(this.fraction() * arrayOrString.length); + if (typeof arrayOrString === "string") + return arrayOrString.substr(index, 1); + else + return arrayOrString[index]; +}; + +// instantiate RNG. Heuristically collect entropy from various sources when a +// cryptographic PRNG isn't available. // client sources var height = (typeof window !== 'undefined' && window.innerHeight) || @@ -143,12 +182,13 @@ var width = (typeof window !== 'undefined' && window.innerWidth) || var agent = (typeof navigator !== 'undefined' && navigator.userAgent) || ""; -// server sources -var pid = (typeof process !== 'undefined' && process.pid) || 1; +if (nodeCrypto || + (typeof window !== "undefined" && + window.crypto && window.crypto.getRandomValues)) + Random = new RandomGenerator(); +else + Random = new RandomGenerator([new Date(), height, width, agent, Math.random()]); -// XXX On the server, use the crypto module (OpenSSL) instead of this PRNG. -// (Make Random.fraction be generated from Random.hexString instead of the -// other way around, and generate Random.hexString from crypto.randomBytes.) -Random = create([ - new Date(), height, width, agent, pid, Math.random() -]); +Random.create = function () { + return new RandomGenerator(arguments); +}; diff --git a/packages/random/random_tests.js b/packages/random/random_tests.js index 52e5b852e5..940afbb640 100644 --- a/packages/random/random_tests.js +++ b/packages/random/random_tests.js @@ -13,3 +13,17 @@ Tinytest.add('random', function (test) { test.equal(random.id(), "shxDnjWWmnKPEoLhM"); test.equal(random.id(), "6QTjB8C5SEqhmz4ni"); }); + +// node crypto and window.crypto.getRandomValues() don't let us specify a seed, +// but at least test that the output is in the right format. +Tinytest.add('random - format', function (test) { + var idLen = 17; + test.equal(Random.id().length, idLen); + var numDigits = 9; + var hexStr = Random.hexString(numDigits); + test.equal(hexStr.length, numDigits); + parseInt(hexStr, 16); // should not throw + var frac = Random.fraction(); + test.isTrue(frac < 1.0); + test.isTrue(frac >= 0.0); +}); diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js index cab0f7ea94..e543ade932 100644 --- a/packages/spark/spark_tests.js +++ b/packages/spark/spark_tests.js @@ -1876,7 +1876,7 @@ Tinytest.add("spark - leaderboard, " + idGeneration, function(test) { })); var idGen; if (idGeneration === 'STRING') - idGen = Random.id; + idGen = _.bind(Random.id, Random); else idGen = function () { return new LocalCollection._ObjectID(); }; diff --git a/packages/test-helpers/seeded_random.js b/packages/test-helpers/seeded_random.js index 0094a20258..171a970486 100644 --- a/packages/test-helpers/seeded_random.js +++ b/packages/test-helpers/seeded_random.js @@ -3,7 +3,7 @@ SeededRandom = function(seed) { // seed may be a string or any type return new SeededRandom(seed); seed = seed || "seed"; - this.gen = new Random._Alea(seed); // from random.js + this.gen = Random.create(seed)._Alea; // from random.js }; SeededRandom.prototype.next = function() { return this.gen(); From 399028eb53b41b5d1bb3cd645bc6f292a35a729d Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 25 Sep 2013 18:13:46 -0700 Subject: [PATCH 091/335] Don't allow clients to call update methods as upserts. Add test for it. This is a way to avoid the complexity of allow/deny rules for upserts; for now, if you want to do an upsert, do it in a method. --- packages/mongo-livedata/allow_tests.js | 13 ++++++++++++- packages/mongo-livedata/collection.js | 6 ++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/mongo-livedata/allow_tests.js b/packages/mongo-livedata/allow_tests.js index b9c50f97b1..3a085c2ece 100644 --- a/packages/mongo-livedata/allow_tests.js +++ b/packages/mongo-livedata/allow_tests.js @@ -431,6 +431,18 @@ if (Meteor.isClient) { test.equal(collection.find({updated: true}).count(), 2); })); }, + // upsert not allowed, and has nice error. + function (test, expect) { + collection.update( + {_id: id2}, + {$set: { upserted: true }}, + { upsert: true }, + expect(function (err, res) { + test.equal(err.error, 403); + test.matches(err.reason, /In a restricted/); + test.equal(collection.find({ upserted: true }).count(), 0); + })); + }, // update with rename operator not allowed, and has nice error. function (test, expect) { collection.update( @@ -778,4 +790,3 @@ if (Meteor.isServer) { delete Package.insecure; }); } - diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index ffb33545d3..23479ce7bd 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -699,9 +699,15 @@ Meteor.Collection.prototype._validatedUpdate = function( userId, selector, mutator, options) { var self = this; + options = options || {}; + if (!LocalCollection._selectorIsIdPerhapsAsObject(selector)) throw new Error("validated update should be of a single ID"); + if (options.upsert) + throw new Meteor.Error(403, "Access denied. In a restricted collection " + + "you cannot do upserts."); + // compute modified fields var fields = []; _.each(mutator, function (params, op) { From 86565853b15307d248a964b4bdc85390f0f5d630 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Wed, 25 Sep 2013 18:19:59 -0700 Subject: [PATCH 092/335] Implement upsert in minimongo. i.e. delete the code where we throw an exception if you try to upsert. --- packages/minimongo/minimongo.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 1735dd646c..2596ee5a3b 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -554,9 +554,6 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { } if (!options) options = {}; - if (options.upsert) - throw new Error("upsert not yet implemented"); - var selector_f = LocalCollection._compileSelector(selector); // Save the original results of any query that we might need to From 115164e4af84095e9399f4f04458906801ac8b85 Mon Sep 17 00:00:00 2001 From: Nicklas Ansman Giertz Date: Sat, 17 Aug 2013 18:35:40 -0700 Subject: [PATCH 093/335] Add the option to use a hosted domain with google's oauth The hd option is used to restrict which email domain that are allowed to log in to your app. Starting from this commit you can pass `hostedDomain: 'example.com'` to only allow emails from the domain `example.com`. --- packages/google/google_client.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/google/google_client.js b/packages/google/google_client.js index 4f8c29c1a3..c99c376b1b 100644 --- a/packages/google/google_client.js +++ b/packages/google/google_client.js @@ -44,6 +44,10 @@ Google.requestCredential = function (options, credentialRequestCompleteCallback) '&access_type=' + accessType + '&approval_prompt=' + approvalPrompt; + if (options.hostedDomain) { + loginUrl += '&hd=' + encodeURIComponent(options.hostedDomain); + } + Oauth.initiateLogin(credentialToken, loginUrl, credentialRequestCompleteCallback, From 79d900cf65900c382bdc13d209e139a6b40e75aa Mon Sep 17 00:00:00 2001 From: David Glasser Date: Wed, 25 Sep 2013 22:34:56 -0700 Subject: [PATCH 094/335] Remove random from leaderboard and wordplay .meteor/packages. It's part of standard-app-packages (though it wasn't added to it until relatively late in the linker project). --- examples/leaderboard/.meteor/packages | 1 - examples/wordplay/.meteor/packages | 1 - 2 files changed, 2 deletions(-) diff --git a/examples/leaderboard/.meteor/packages b/examples/leaderboard/.meteor/packages index a5eb137152..240f048420 100644 --- a/examples/leaderboard/.meteor/packages +++ b/examples/leaderboard/.meteor/packages @@ -7,4 +7,3 @@ standard-app-packages autopublish insecure preserve-inputs -random diff --git a/examples/wordplay/.meteor/packages b/examples/wordplay/.meteor/packages index 1c4346821c..e695b93a6f 100644 --- a/examples/wordplay/.meteor/packages +++ b/examples/wordplay/.meteor/packages @@ -7,4 +7,3 @@ standard-app-packages insecure jquery preserve-inputs -random From a6db08ce8d14edf65e355c9f307b5b8eb8afad47 Mon Sep 17 00:00:00 2001 From: Matt DeBergalis Date: Wed, 25 Sep 2013 23:00:32 -0700 Subject: [PATCH 095/335] Improve the explanation of manual publisher functions. Fixes #1018. --- docs/client/api.html | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/client/api.html b/docs/client/api.html index 3aa992a512..e3f96a1f90 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -62,9 +62,9 @@ that Meteor will call each time a client subscribes to the name. Publish functions can return a [`Collection.Cursor`](#meteor_collection_cursor), in which case Meteor -will publish that cursor's documents. You can also return an array of -`Collection.Cursor`s, in which case Meteor will publish all of the -cursors. +will publish that cursor's documents to each subscribed client. You can +also return an array of `Collection.Cursor`s, in which case Meteor will +publish all of the cursors. {{#warning}} If you return multiple cursors in an array, they currently must all be from @@ -92,16 +92,15 @@ different collections. We hope to lift this restriction in a future release. ]; }); -Otherwise, the publish function should call the functions -[`added`](#publish_added) (when a new document is added to the published record -set), [`changed`](#publish_changed) (when some fields on a document in the -record set are changed or cleared), and [`removed`](#publish_removed) (when -documents are removed from the published record set) to inform subscribers about -documents. These methods are provided by `this` in your publish function. - - - - +Alternatively, a publish function can directly control its published +record set by calling the functions [`added`](#publish_added) (to add a +new document to the published record set), [`changed`](#publish_changed) +(to change or clear some fields on a document already in the published +record set), and [`removed`](#publish_removed) (to remove documents from +the published record set). Publish functions that use these functions +should also call [`ready`](#publish_ready) once the initial record set +is complete. These methods are provided by `this` in your publish +function. Example: From fcf6fdaa71803d9beb51d7c9965e356673df04cc Mon Sep 17 00:00:00 2001 From: Matt DeBergalis Date: Wed, 25 Sep 2013 23:28:15 -0700 Subject: [PATCH 096/335] Clean up docs for collections managed by remote DDP server. Fixes #875. --- docs/client/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client/api.js b/docs/client/api.js index 1ca9cbce0f..f9f028d024 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -482,7 +482,7 @@ Template.api.meteor_collection = { options: [ {name: "connection", type: "Object", - descr: "The Meteor connection that will manage this collection. Uses the default connection if not specified. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection." + descr: "The server connection that will manage this collection. Uses the default connection if not specified. Pass the return value of calling [`DDP.connect`](#ddp_connect) to specify a different server. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection." }, {name: "idGeneration", type: "String", From 0559491e875a64d773a4fdc61857f455e30117a3 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Thu, 26 Sep 2013 13:33:42 -0500 Subject: [PATCH 097/335] Improve Mongo compatibility with $ne/$nin/$not and arrays. Fixes #1451. We currently handle matches where the key has multiple values due to arrays in the document in two subtley different ways, depending on whether or not the array is in the last element of the keypath or not. (This is annoyingly necessary, at least in the current structure of how we compile selectors, due to various selectors differing in how they treat arrays.) The negative-style operators have "must match all values" semantics, but this was only being enforced when the branching was in the last element of the keypath. This commit semi-hackily applies those semantics for other branching too. We are still not 100% Mongo compatible (see XXX comment) but it's closer. --- packages/minimongo/minimongo_tests.js | 7 +++++++ packages/minimongo/selector.js | 22 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 07475e5c94..5f19e703e0 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -427,6 +427,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) { nomatch({a: {$ne: 1}}, {a: [1, 2]}); nomatch({a: {$ne: 2}}, {a: [1, 2]}); match({a: {$ne: 3}}, {a: [1, 2]}); + nomatch({'a.b': {$ne: 1}}, {a: [{b: 1}, {b: 2}]}); + nomatch({'a.b': {$ne: 2}}, {a: [{b: 1}, {b: 2}]}); + match({'a.b': {$ne: 3}}, {a: [{b: 1}, {b: 2}]}); nomatch({a: {$ne: {x: 1}}}, {a: {x: 1}}); match({a: {$ne: {x: 1}}}, {a: {x: 2}}); @@ -456,7 +459,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) { nomatch({a: {$nin: [1, 2, 3]}}, {a: [2]}); // tested against mongodb nomatch({a: {$nin: [{x: 1}, {x: 2}, {x: 3}]}}, {a: [{x: 2}]}); nomatch({a: {$nin: [1, 2, 3]}}, {a: [4, 2]}); + nomatch({'a.b': {$nin: [1, 2, 3]}}, {a: [{b:4}, {b:2}]}); match({a: {$nin: [1, 2, 3]}}, {a: [4]}); + match({'a.b': {$nin: [1, 2, 3]}}, {a: [{b:4}]}); // $size match({a: {$size: 0}}, {a: []}); @@ -564,7 +569,9 @@ Tinytest.add("minimongo - selector_compiler", function (test) { match({x: {$not: {$lt: 10, $gt: 7}}}, {x: 6}); match({x: {$not: {$gt: 7}}}, {x: [2, 3, 4]}); + match({'x.y': {$not: {$gt: 7}}}, {x: [{y:2}, {y:3}, {y:4}]}); nomatch({x: {$not: {$gt: 7}}}, {x: [2, 3, 4, 10]}); + nomatch({'x.y': {$not: {$gt: 7}}}, {x: [{y:2}, {y:3}, {y:4}, {y:10}]}); match({x: {$not: /a/}}, {x: "dog"}); nomatch({x: {$not: /a/}}, {x: "cat"}); diff --git a/packages/minimongo/selector.js b/packages/minimongo/selector.js index 7cf8fa0a5d..057e0d9195 100644 --- a/packages/minimongo/selector.js +++ b/packages/minimongo/selector.js @@ -551,9 +551,25 @@ var compileDocumentSelector = function (docSelector) { perKeySelectors.push(function (doc) { var branchValues = lookUpByIndex(doc); // We apply the selector to each "branched" value and return true if any - // match. This isn't 100% consistent with MongoDB; eg, see: - // https://jira.mongodb.org/browse/SERVER-8585 - return _.any(branchValues, valueSelectorFunc); + // match. However, for "negative" selectors like $ne or $not we actually + // require *all* elements to match. + // + // This is because {'x.tag': {$ne: "foo"}} applied to {x: [{tag: 'foo'}, + // {tag: 'bar'}]} should NOT match even though there is a branch that + // matches. (This matches the fact that $ne uses a negated + // _anyIfArrayPlus, for when the last level of the key is the array, + // which deMorgans into an 'all'.) + // + // XXX This isn't 100% consistent with MongoDB in 'null' cases: + // https://jira.mongodb.org/browse/SERVER-8585 + // XXX this still isn't right. consider {a: {$ne: 5, $gt: 6}}. the + // $ne needs to use the "all" logic and the $gt needs the "any" + // logic + var combiner = (subSelector && + (subSelector.$not || subSelector.$ne || + subSelector.$nin)) + ? _.all : _.any; + return combiner(branchValues, valueSelectorFunc); }); } }); From bad4c097c24eaf13d0045898c85b94669fc99003 Mon Sep 17 00:00:00 2001 From: Tim Haines Date: Tue, 24 Sep 2013 12:31:21 -0700 Subject: [PATCH 098/335] Add the error message to the Meteor._debug call --- packages/livedata/livedata_server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 11bd6752a5..b675dc40d6 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -1018,7 +1018,7 @@ Server = function () { } catch (e) { // XXX print stack nicely Meteor._debug("Internal exception while processing message", msg, - e.stack); + e.message, e.stack); } }); From c8308cdf9fcdf49bb91011e0b7ac9071a79f565d Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Thu, 26 Sep 2013 13:48:01 -0700 Subject: [PATCH 099/335] Turn off mongo http interface. This lets you run meteor on port 3000 and 4000 at the same time. --- tools/mongo_runner.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/mongo_runner.js b/tools/mongo_runner.js index c39e2e64cf..b2c1b55093 100644 --- a/tools/mongo_runner.js +++ b/tools/mongo_runner.js @@ -161,6 +161,7 @@ exports.launch_mongo = function (app_dir, port, launch_callback, on_exit_callbac var proc = child_process.spawn(mongod_path, [ '--bind_ip', '127.0.0.1', '--smallfiles', + '--nohttpinterface', '--port', port, '--dbpath', data_path ]); From 0286395700d4e2abcacb1fb34ae89254d4adde48 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 26 Sep 2013 14:22:23 -0700 Subject: [PATCH 100/335] Actually implement minimongo upsert. Got a little over-confident there... --- packages/minimongo/minimongo.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 2596ee5a3b..86fe4df943 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -473,6 +473,7 @@ LocalCollection.prototype.insert = function (doc, callback) { LocalCollection._recomputeResults(self.queries[qid]); }); self._observeQueue.drain(); + // Defer because the caller likely doesn't expect the callback to be run // immediately. if (callback) @@ -589,7 +590,20 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { qidToOriginalResults[qid]); }); self._observeQueue.drain(); - var result = { numberAffected: updateCount }; + + var insertedId; + if (updateCount === 0 && options.upsert) { + var newDoc = _.clone(selector); + LocalCollection._modify(newDoc, mod); + insertedId = self.insert(newDoc); + updateCount = 1; + } + + var result = { + numberAffected: updateCount + }; + if (insertedId !== undefined) + result.insertedId = insertedId; if (callback) Meteor.defer(function () { callback(null, result); From 1964d2e88ec52b53561cd7d9ccc82651a42b2336 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 26 Sep 2013 14:48:13 -0700 Subject: [PATCH 101/335] Support in minimongo --- packages/minimongo/minimongo.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 86fe4df943..c1f2a21771 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -549,12 +549,19 @@ LocalCollection.prototype.remove = function (selector, callback) { // we rollback the whole operation, or what? LocalCollection.prototype.update = function (selector, mod, options, callback) { var self = this; + var newMod = _.clone(mod); if (! callback && options instanceof Function) { callback = options; options = null; } if (!options) options = {}; + var setOnInsert; + if (newMod.$setOnInsert) { + setOnInsert = _.clone(newMod.$setOnInsert); + delete newMod.$setOnInsert; + } + var selector_f = LocalCollection._compileSelector(selector); // Save the original results of any query that we might need to @@ -576,7 +583,7 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { if (selector_f(doc)) { // XXX Should we save the original even if mod ends up being a no-op? self._saveOriginal(id, doc); - self._modifyAndNotify(doc, mod, recomputeQids); + self._modifyAndNotify(doc, newMod, recomputeQids); ++updateCount; if (!options.multi) break; @@ -591,10 +598,18 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { }); self._observeQueue.drain(); + if (setOnInsert) + newMod.$setOnInsert = setOnInsert; + var insertedId; if (updateCount === 0 && options.upsert) { var newDoc = _.clone(selector); - LocalCollection._modify(newDoc, mod); + if (newMod.$setOnInsert) { + newMod.$set = _.extend(newMod.$set || {}, + _.clone(newMod.$setOnInsert)); + delete newMod.$setOnInsert; + } + LocalCollection._modify(newDoc, newMod); insertedId = self.insert(newDoc); updateCount = 1; } From 58ad24d9328863c79120a4ffd006468f073bd705 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Thu, 26 Sep 2013 14:48:53 -0700 Subject: [PATCH 102/335] Run upsert tests for minimongo collections --- .../mongo-livedata/mongo_livedata_tests.js | 137 +++++++++--------- 1 file changed, 71 insertions(+), 66 deletions(-) diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 8f529066cb..b48a4a7771 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -915,83 +915,88 @@ if (Meteor.isServer) { onComplete(); }); - Tinytest.addAsync("mongo-livedata - upsert, " + idGeneration, function (test, onComplete) { - var run = test.runId(); - var coll = new Meteor.Collection("livedata_upsert_collection_"+run, collectionOptions); + _.each([true, false], function (minimongo) { + Tinytest.addAsync("mongo-livedata - upsert " + (minimongo ? "minimongo" : "") + ", " + idGeneration, function (test, onComplete) { + var run = test.runId(); + var options = collectionOptions;; + if (minimongo) + options = _.extend(collectionOptions, { connection: null }); + var coll = new Meteor.Collection("livedata_upsert_collection_"+run, options); - var result1 = coll.update({foo: 'bar'}, {foo: 'bar'}, {upsert: true}); - test.equal(result1.numberAffected, 1); - test.isTrue(result1.insertedId); - test.equal(coll.find().fetch(), [{foo: 'bar', _id: result1.insertedId}]); + var result1 = coll.update({foo: 'bar'}, {foo: 'bar'}, {upsert: true}); + test.equal(result1.numberAffected, 1); + test.isTrue(result1.insertedId); + test.equal(coll.find().fetch(), [{foo: 'bar', _id: result1.insertedId}]); - var result2 = coll.update({foo: 'bar'}, {foo: 'baz'}, {upsert: true}); - test.equal(result2.numberAffected, 1); - test.isFalse(result2.insertedId); - test.equal(coll.find().fetch(), [{foo: 'baz', _id: result1.insertedId}]); + var result2 = coll.update({foo: 'bar'}, {foo: 'baz'}, {upsert: true}); + test.equal(result2.numberAffected, 1); + test.isFalse(result2.insertedId); + test.equal(coll.find().fetch(), [{foo: 'baz', _id: result1.insertedId}]); - coll.remove({}); + coll.remove({}); - // Test values that require transformation to go into Mongo: + // Test values that require transformation to go into Mongo: - var t1 = new Meteor.Collection.ObjectID(); - var t2 = new Meteor.Collection.ObjectID(); - var result3 = coll.update({foo: t1}, {foo: t1}, {upsert: true}); - test.equal(result3.numberAffected, 1); - test.isTrue(result3.insertedId); - test.equal(coll.find().fetch(), [{foo: t1, _id: result3.insertedId}]); + var t1 = new Meteor.Collection.ObjectID(); + var t2 = new Meteor.Collection.ObjectID(); + var result3 = coll.update({foo: t1}, {foo: t1}, {upsert: true}); + test.equal(result3.numberAffected, 1); + test.isTrue(result3.insertedId); + test.equal(coll.find().fetch(), [{foo: t1, _id: result3.insertedId}]); - var result4 = coll.update({foo: t1}, {foo: t2}, {upsert: true}); - test.equal(result2.numberAffected, 1); - test.isFalse(result2.insertedId); - test.equal(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); + var result4 = coll.update({foo: t1}, {foo: t2}, {upsert: true}); + test.equal(result2.numberAffected, 1); + test.isFalse(result2.insertedId); + test.equal(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); - coll.remove({}); + coll.remove({}); - // Test modification + // Test modification - var result5 = coll.update({name: 'David'}, {$set: {foo: 1}}, {upsert: true}); - test.equal(result5.numberAffected, 1); - test.isTrue(result5.insertedId); - var davidId = result5.insertedId; - test.equal(coll.find().fetch(), [{name: 'David', foo: 1, _id: davidId}]); + var result5 = coll.update({name: 'David'}, {$set: {foo: 1}}, {upsert: true}); + test.equal(result5.numberAffected, 1); + test.isTrue(result5.insertedId); + var davidId = result5.insertedId; + test.equal(coll.find().fetch(), [{name: 'David', foo: 1, _id: davidId}]); - test.throws(function () { - // test that bad modifier fails fast - coll.update({name: 'David'}, {$blah: {foo: 2}}, {upsert: true}); + test.throws(function () { + // test that bad modifier fails fast + coll.update({name: 'David'}, {$blah: {foo: 2}}, {upsert: true}); + }); + + + var result6 = coll.update({name: 'David'}, {$set: {foo: 2}}, {upsert: true}); + test.equal(result6.numberAffected, 1); + test.isFalse(result6.insertedId); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, + _id: result5.insertedId}]); + + var emilyId = coll.insert({name: 'Emily', foo: 2}); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, _id: davidId}, + {name: 'Emily', foo: 2, _id: emilyId}]); + + // multi update by upsert + var result7 = coll.update({foo: 2}, + {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, + {upsert: true, multi: true}); + test.equal(result7.numberAffected, 2); + test.isFalse(result7.insertedId); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}]); + + // insert by multi upsert + var result8 = coll.update({foo: 3}, + {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, + {upsert: true, multi: true}); + test.equal(result8.numberAffected, 1); + test.isTrue(result8.insertedId); + var fredId = result8.insertedId; + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, + {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); + + onComplete(); }); - - - var result6 = coll.update({name: 'David'}, {$set: {foo: 2}}, {upsert: true}); - test.equal(result6.numberAffected, 1); - test.isFalse(result6.insertedId); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, - _id: result5.insertedId}]); - - var emilyId = coll.insert({name: 'Emily', foo: 2}); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, _id: davidId}, - {name: 'Emily', foo: 2, _id: emilyId}]); - - // multi update by upsert - var result7 = coll.update({foo: 2}, - {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, - {upsert: true, multi: true}); - test.equal(result7.numberAffected, 2); - test.isFalse(result7.insertedId); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, - {name: 'Emily', foo: 2, bar: 7, _id: emilyId}]); - - // insert by multi upsert - var result8 = coll.update({foo: 3}, - {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, - {upsert: true, multi: true}); - test.equal(result8.numberAffected, 1); - test.isTrue(result8.insertedId); - var fredId = result8.insertedId; - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, - {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, - {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); - - onComplete(); }); } // end Meteor.isServer From d855e58f9220e8ee772c1b414777282291bff084 Mon Sep 17 00:00:00 2001 From: Slava Kim Date: Wed, 25 Sep 2013 20:41:37 -0700 Subject: [PATCH 103/335] Implement Accounts.config({ restrictCreationByEmail: 'mit.edu' }) - Check email for users created with password or any social account's email - Throw an error with explanation on bad email domain. - Set `hd` param for Google Accounts authentication url - Docs description - Touch History.md - Possibly should add it into QA process? --- History.md | 3 +++ docs/client/api.js | 5 ++++ packages/accounts-base/accounts_common.js | 5 ++-- packages/accounts-base/accounts_server.js | 28 +++++++++++++++++++++++ packages/google/google_client.js | 4 ++-- 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/History.md b/History.md index 3db302fed6..4154280e35 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,8 @@ ## vNEXT +* `restrictCreationByEmail` option in `Accounts.config` to restrict new users to + emails of specific domain (eg. only users with @meteor.com emails). + * Better error when passing a string to {{#each}}. #722 * Write dates to Mongo as ISODate rather than Integer; existing data can be diff --git a/docs/client/api.js b/docs/client/api.js index 1ca9cbce0f..a75a564257 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -1100,6 +1100,11 @@ Template.api.accounts_config = { name: "forbidClientAccountCreation", type: "Boolean", descr: "Calls to [`createUser`](#accounts_createuser) from the client will be rejected. In addition, if you are using [accounts-ui](#accountsui), the \"Create account\" link will not be available." + }, + { + name: "restrictCreationByEmail", + type: "String", + descr: "If set, only allow new users with an email in the specified domain. Works with password-based sign-in and external services that expose email addresses (Google, Facebook, GitHub). All existing users still can log in after enabling this option. Example: `Accounts.config({ restrictCreationByEmail: 'school.edu' })`." } ] }; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 1de58f1b2d..085591b128 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -34,8 +34,9 @@ Accounts._options = {}; Accounts.config = function(options) { // validate option keys var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation", - "_tokenLifetimeSecs", "_tokenExpirationIntervalSecs", - "_minTokenLifetimeSecs", "_connectionCloseDelaySecs"]; + "restrictCreationByEmail", "_tokenLifetimeSecs", + "_tokenExpirationIntervalSecs", "_minTokenLifetimeSecs", + "_connectionCloseDelaySecs"]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) { throw new Error("Accounts.config: Invalid key: " + key); diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index c97f0e17ab..c36bc5bf47 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -304,6 +304,34 @@ Accounts.validateNewUser = function (func) { validateNewUserHooks.push(func); }; +// Helper function: returns false if email does not match company domain from +// the configuration. +var testEmailDomain = function (email) { + var domain = Accounts._options.restrictCreationByEmail; + return !domain || (new RegExp('@' + domain + '$', 'i')).test(email); +}; + +// Validate new user's email or Google/Facebook/Github account's email +Accounts.validateNewUser(function (user) { + var domain = Accounts._options.restrictCreationByEmail; + if (!domain) + return true; + + var emailIsGood = true; + // User with password can have only one email on creation + if (user.emails) + emailIsGood &= testEmailDomain(user.emails[0].address); + + // Find any email of any service and check it + emailIsGood &= _.any(user.services, function (service) { + return service.email && testEmailDomain(service.email); + }); + + if (!emailIsGood) + throw new Meteor.Error(403, "@" + domain + " email required"); + + return true; +}); /// /// MANAGING USER OBJECTS diff --git a/packages/google/google_client.js b/packages/google/google_client.js index c99c376b1b..fce4e19750 100644 --- a/packages/google/google_client.js +++ b/packages/google/google_client.js @@ -44,8 +44,8 @@ Google.requestCredential = function (options, credentialRequestCompleteCallback) '&access_type=' + accessType + '&approval_prompt=' + approvalPrompt; - if (options.hostedDomain) { - loginUrl += '&hd=' + encodeURIComponent(options.hostedDomain); + if (Accounts._options.restrictCreationByEmail) { + loginUrl += '&hd=' + encodeURIComponent(Accounts._options.restrictCreationByEmail); } Oauth.initiateLogin(credentialToken, From 3f6c14aedfb97ac264f4ad738c221467ce0928c9 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 12:37:22 -0700 Subject: [PATCH 104/335] fix tests --- packages/mongo-livedata/mongo_livedata_tests.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index b48a4a7771..1c7d0f3d92 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -918,9 +918,9 @@ if (Meteor.isServer) { _.each([true, false], function (minimongo) { Tinytest.addAsync("mongo-livedata - upsert " + (minimongo ? "minimongo" : "") + ", " + idGeneration, function (test, onComplete) { var run = test.runId(); - var options = collectionOptions;; + var options = collectionOptions; if (minimongo) - options = _.extend(collectionOptions, { connection: null }); + options = _.extend({}, collectionOptions, { connection: null }); var coll = new Meteor.Collection("livedata_upsert_collection_"+run, options); var result1 = coll.update({foo: 'bar'}, {foo: 'bar'}, {upsert: true}); From 1dd83b1d2c5f5d72e009c83aea900536cf5465bf Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 13:18:09 -0700 Subject: [PATCH 105/335] run minimongo upsert tests on client (has errors) --- .../mongo-livedata/mongo_livedata_tests.js | 168 +++++++++--------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 1c7d0f3d92..277f26ec0e 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -915,92 +915,92 @@ if (Meteor.isServer) { onComplete(); }); - _.each([true, false], function (minimongo) { - Tinytest.addAsync("mongo-livedata - upsert " + (minimongo ? "minimongo" : "") + ", " + idGeneration, function (test, onComplete) { - var run = test.runId(); - var options = collectionOptions; - if (minimongo) - options = _.extend({}, collectionOptions, { connection: null }); - var coll = new Meteor.Collection("livedata_upsert_collection_"+run, options); - - var result1 = coll.update({foo: 'bar'}, {foo: 'bar'}, {upsert: true}); - test.equal(result1.numberAffected, 1); - test.isTrue(result1.insertedId); - test.equal(coll.find().fetch(), [{foo: 'bar', _id: result1.insertedId}]); - - var result2 = coll.update({foo: 'bar'}, {foo: 'baz'}, {upsert: true}); - test.equal(result2.numberAffected, 1); - test.isFalse(result2.insertedId); - test.equal(coll.find().fetch(), [{foo: 'baz', _id: result1.insertedId}]); - - coll.remove({}); - - // Test values that require transformation to go into Mongo: - - var t1 = new Meteor.Collection.ObjectID(); - var t2 = new Meteor.Collection.ObjectID(); - var result3 = coll.update({foo: t1}, {foo: t1}, {upsert: true}); - test.equal(result3.numberAffected, 1); - test.isTrue(result3.insertedId); - test.equal(coll.find().fetch(), [{foo: t1, _id: result3.insertedId}]); - - var result4 = coll.update({foo: t1}, {foo: t2}, {upsert: true}); - test.equal(result2.numberAffected, 1); - test.isFalse(result2.insertedId); - test.equal(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); - - coll.remove({}); - - // Test modification - - var result5 = coll.update({name: 'David'}, {$set: {foo: 1}}, {upsert: true}); - test.equal(result5.numberAffected, 1); - test.isTrue(result5.insertedId); - var davidId = result5.insertedId; - test.equal(coll.find().fetch(), [{name: 'David', foo: 1, _id: davidId}]); - - test.throws(function () { - // test that bad modifier fails fast - coll.update({name: 'David'}, {$blah: {foo: 2}}, {upsert: true}); - }); - - - var result6 = coll.update({name: 'David'}, {$set: {foo: 2}}, {upsert: true}); - test.equal(result6.numberAffected, 1); - test.isFalse(result6.insertedId); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, - _id: result5.insertedId}]); - - var emilyId = coll.insert({name: 'Emily', foo: 2}); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, _id: davidId}, - {name: 'Emily', foo: 2, _id: emilyId}]); - - // multi update by upsert - var result7 = coll.update({foo: 2}, - {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, - {upsert: true, multi: true}); - test.equal(result7.numberAffected, 2); - test.isFalse(result7.insertedId); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, - {name: 'Emily', foo: 2, bar: 7, _id: emilyId}]); - - // insert by multi upsert - var result8 = coll.update({foo: 3}, - {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, - {upsert: true, multi: true}); - test.equal(result8.numberAffected, 1); - test.isTrue(result8.insertedId); - var fredId = result8.insertedId; - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, - {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, - {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); - - onComplete(); - }); - }); - } // end Meteor.isServer +_.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { + Tinytest.addAsync("mongo-livedata - upsert " + (minimongo ? "minimongo" : "") + ", " + idGeneration, function (test, onComplete) { + var run = test.runId(); + var options = collectionOptions; + if (minimongo) + options = _.extend({}, collectionOptions, { connection: null }); + var coll = new Meteor.Collection("livedata_upsert_collection_"+run, options); + + var result1 = coll.update({foo: 'bar'}, {foo: 'bar'}, {upsert: true}); + test.equal(result1.numberAffected, 1); + test.isTrue(result1.insertedId); + test.equal(coll.find().fetch(), [{foo: 'bar', _id: result1.insertedId}]); + + var result2 = coll.update({foo: 'bar'}, {foo: 'baz'}, {upsert: true}); + test.equal(result2.numberAffected, 1); + test.isFalse(result2.insertedId); + test.equal(coll.find().fetch(), [{foo: 'baz', _id: result1.insertedId}]); + + coll.remove({}); + + // Test values that require transformation to go into Mongo: + + var t1 = new Meteor.Collection.ObjectID(); + var t2 = new Meteor.Collection.ObjectID(); + var result3 = coll.update({foo: t1}, {foo: t1}, {upsert: true}); + test.equal(result3.numberAffected, 1); + test.isTrue(result3.insertedId); + test.equal(coll.find().fetch(), [{foo: t1, _id: result3.insertedId}]); + + var result4 = coll.update({foo: t1}, {foo: t2}, {upsert: true}); + test.equal(result2.numberAffected, 1); + test.isFalse(result2.insertedId); + test.equal(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); + + coll.remove({}); + + // Test modification + + var result5 = coll.update({name: 'David'}, {$set: {foo: 1}}, {upsert: true}); + test.equal(result5.numberAffected, 1); + test.isTrue(result5.insertedId); + var davidId = result5.insertedId; + test.equal(coll.find().fetch(), [{name: 'David', foo: 1, _id: davidId}]); + + test.throws(function () { + // test that bad modifier fails fast + coll.update({name: 'David'}, {$blah: {foo: 2}}, {upsert: true}); + }); + + + var result6 = coll.update({name: 'David'}, {$set: {foo: 2}}, {upsert: true}); + test.equal(result6.numberAffected, 1); + test.isFalse(result6.insertedId); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, + _id: result5.insertedId}]); + + var emilyId = coll.insert({name: 'Emily', foo: 2}); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, _id: davidId}, + {name: 'Emily', foo: 2, _id: emilyId}]); + + // multi update by upsert + var result7 = coll.update({foo: 2}, + {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, + {upsert: true, multi: true}); + test.equal(result7.numberAffected, 2); + test.isFalse(result7.insertedId); + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}]); + + // insert by multi upsert + var result8 = coll.update({foo: 3}, + {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, + {upsert: true, multi: true}); + test.equal(result8.numberAffected, 1); + test.isTrue(result8.insertedId); + var fredId = result8.insertedId; + test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, + {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); + + onComplete(); + }); +}); + }); // end idGeneration parametrization Tinytest.add('mongo-livedata - rewrite selector', function (test) { From e1aa2450e9ad4c0bb4c7ae7f6987af9352b3aa99 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 13:38:50 -0700 Subject: [PATCH 106/335] don't swallow exceptions on LocalCollection write We provide an error-logging callback for async writes without a callback. However, writes to LocalCollections are not necessarily async, so we shouldn't provide a callback. --- packages/mongo-livedata/collection.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 23479ce7bd..ee6b36a434 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -347,18 +347,6 @@ _.each(["insert", "update", "remove"], function (name) { if (args.length && args[args.length - 1] instanceof Function) callback = args.pop(); - if (Meteor.isClient && !callback) { - // Client can't block, so it can't report errors by exception, - // only by callback. If they forget the callback, give them a - // default one that logs the error, so they aren't totally - // baffled if their writes don't work because their database is - // down. - callback = function (err) { - if (err) - Meteor._debug(name + " failed: " + (err.reason || err.stack)); - }; - } - if (name === "insert") { if (!args.length) throw new Error("insert requires an argument"); @@ -407,6 +395,18 @@ _.each(["insert", "update", "remove"], function (name) { // just remote to another endpoint, propagate return value or // exception. + if (Meteor.isClient && !wrappedCallback) { + // Client can't block, so it can't report errors by exception, + // only by callback. If they forget the callback, give them a + // default one that logs the error, so they aren't totally + // baffled if their writes don't work because their database is + // down. + wrappedCallback = function (err) { + if (err) + Meteor._debug(name + " failed: " + (err.reason || err.stack)); + }; + } + var enclosing = DDP._CurrentInvocation.get(); var alreadyInSimulation = enclosing && enclosing.isSimulation; if (!alreadyInSimulation && name !== "insert") { From d8d9206f4b630f4956f5a3ad3a66a65282c19d5e Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 13:53:09 -0700 Subject: [PATCH 107/335] $setOnInsert in minimongo --- packages/minimongo/minimongo.js | 19 ++----------------- packages/minimongo/modify.js | 3 +++ 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index c1f2a21771..59cd1be837 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -549,19 +549,12 @@ LocalCollection.prototype.remove = function (selector, callback) { // we rollback the whole operation, or what? LocalCollection.prototype.update = function (selector, mod, options, callback) { var self = this; - var newMod = _.clone(mod); if (! callback && options instanceof Function) { callback = options; options = null; } if (!options) options = {}; - var setOnInsert; - if (newMod.$setOnInsert) { - setOnInsert = _.clone(newMod.$setOnInsert); - delete newMod.$setOnInsert; - } - var selector_f = LocalCollection._compileSelector(selector); // Save the original results of any query that we might need to @@ -583,7 +576,7 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { if (selector_f(doc)) { // XXX Should we save the original even if mod ends up being a no-op? self._saveOriginal(id, doc); - self._modifyAndNotify(doc, newMod, recomputeQids); + self._modifyAndNotify(doc, mod, recomputeQids); ++updateCount; if (!options.multi) break; @@ -598,18 +591,10 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { }); self._observeQueue.drain(); - if (setOnInsert) - newMod.$setOnInsert = setOnInsert; - var insertedId; if (updateCount === 0 && options.upsert) { var newDoc = _.clone(selector); - if (newMod.$setOnInsert) { - newMod.$set = _.extend(newMod.$set || {}, - _.clone(newMod.$setOnInsert)); - delete newMod.$setOnInsert; - } - LocalCollection._modify(newDoc, newMod); + LocalCollection._modify(newDoc, mod, true); insertedId = self.insert(newDoc); updateCount = 1; } diff --git a/packages/minimongo/modify.js b/packages/minimongo/modify.js index 8413899eb1..0e404914dd 100644 --- a/packages/minimongo/modify.js +++ b/packages/minimongo/modify.js @@ -147,6 +147,9 @@ LocalCollection._modifiers = { target[field] = EJSON.clone(arg); }, + $setOnInsert: function (target, field, arg) { + // converted to `$set` in `_modify` + }, $unset: function (target, field, arg) { if (target !== undefined) { if (target instanceof Array) { From 7bf11e35a6c1ca5996802a43b3a2b6c61475e031 Mon Sep 17 00:00:00 2001 From: Robert Lowe Date: Fri, 26 Jul 2013 23:43:20 -0400 Subject: [PATCH 108/335] Adds oauth1 improves to support requestTokenSecrets & dynamic urls. Atmosphere package: https://github.com/RobertLowe/meteor-accounts-trello Fixes #1167 Closes #1227 --- packages/oauth1/oauth1_binding.js | 28 +++++++++++++++++++--------- packages/oauth1/oauth1_server.js | 23 +++++++++++++++++------ packages/oauth1/oauth1_tests.js | 4 +++- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/oauth1/oauth1_binding.js b/packages/oauth1/oauth1_binding.js index 8047f387c6..59b6fdb5d4 100644 --- a/packages/oauth1/oauth1_binding.js +++ b/packages/oauth1/oauth1_binding.js @@ -4,19 +4,18 @@ var querystring = Npm.require("querystring"); // An OAuth1 wrapper around http calls which helps get tokens and // takes care of HTTP headers // -// @param consumerKey {String} As supplied by the OAuth1 provider -// @param consumerSecret {String} As supplied by the OAuth1 provider +// @param config {Object} Keys, Secrets, etc // @param urls {Object} // - requestToken (String): url // - authorize (String): url // - accessToken (String): url // - authenticate (String): url -OAuth1Binding = function(consumerKey, consumerSecret, urls) { - this._consumerKey = consumerKey; - this._secret = consumerSecret; +OAuth1Binding = function(config, urls) { + this._config = config; this._urls = urls; }; + OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { var self = this; @@ -27,15 +26,20 @@ OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { var response = self._call('POST', self._urls.requestToken, headers); var tokens = querystring.parse(response.content); - // XXX should we also store oauth_token_secret here? + // XXX should we also store oauth_token_secret here? Yes, we should. if (!tokens.oauth_callback_confirmed) throw new Error("oauth_callback_confirmed false when requesting oauth1 token", tokens); self.requestToken = tokens.oauth_token; + self.requestTokenSecret = tokens.oauth_token_secret; }; -OAuth1Binding.prototype.prepareAccessToken = function(query) { +OAuth1Binding.prototype.prepareAccessToken = function(query, requestTokenSecret) { var self = this; + // support implemntations that use request token secrets + if (requestTokenSecret) + self.accessTokenSecret = requestTokenSecret + var headers = self._buildHeader({ oauth_token: query.oauth_token }); @@ -76,7 +80,7 @@ OAuth1Binding.prototype.post = function(url, params, callback) { OAuth1Binding.prototype._buildHeader = function(headers) { var self = this; return _.extend({ - oauth_consumer_key: self._consumerKey, + oauth_consumer_key: self._config.consumerKey, oauth_nonce: Random.id().replace(/\W/g, ''), oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: (new Date().valueOf()/1000).toFixed().toString(), @@ -98,7 +102,7 @@ OAuth1Binding.prototype._getSignature = function(method, url, rawHeaders, access self._encodeString(parameters) ].join('&'); - var signingKey = self._encodeString(self._secret) + '&'; + var signingKey = self._encodeString(self._config.secret) + '&'; if (accessTokenSecret) signingKey += self._encodeString(accessTokenSecret); @@ -108,6 +112,12 @@ OAuth1Binding.prototype._getSignature = function(method, url, rawHeaders, access OAuth1Binding.prototype._call = function(method, url, headers, params, callback) { var self = this; + + // callback functions to support parameters/customization + if(typeof(url) == "function"){ + url = url(self); + } + // Get the signature headers.oauth_signature = self._getSignature(method, url, headers, self.accessTokenSecret, params); diff --git a/packages/oauth1/oauth1_server.js b/packages/oauth1/oauth1_server.js index 2e2a530020..b4a6ab5f1c 100644 --- a/packages/oauth1/oauth1_server.js +++ b/packages/oauth1/oauth1_server.js @@ -1,5 +1,6 @@ // A place to store request tokens pending verification var requestTokens = {}; +var querystring = Npm.require("querystring"); OAuth1Test = {requestTokens: requestTokens}; @@ -12,8 +13,7 @@ Oauth._requestHandlers['1'] = function (service, query, res) { } var urls = service.urls; - var oauthBinding = new OAuth1Binding( - config.consumerKey, config.secret, urls); + var oauthBinding = new OAuth1Binding(config, urls); if (query.requestTokenAndRedirect) { // step 1 - get and store a request token @@ -22,10 +22,20 @@ Oauth._requestHandlers['1'] = function (service, query, res) { oauthBinding.prepareRequestToken(query.requestTokenAndRedirect); // Keep track of request token so we can verify it on the next step - requestTokens[query.state] = oauthBinding.requestToken; + requestTokens[query.state] = { + requestToken: oauthBinding.requestToken, + requestTokenSecret: oauthBinding.requestTokenSecret + }; + + // support for scope/name parameters + var redirectUrl = undefined; + if(typeof(urls.authenticate) == "function"){ + redirectUrl = urls.authenticate(oauthBinding); + } else { + redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; + } // redirect to provider login, which will redirect back to "step 2" below - var redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; res.writeHead(302, {'Location': redirectUrl}); res.end(); } else { @@ -34,7 +44,8 @@ Oauth._requestHandlers['1'] = function (service, query, res) { // token and access token secret and log in as user // Get the user's request token so we can verify it and clear it - var requestToken = requestTokens[query.state]; + var requestToken = requestTokens[query.state].requestToken; + var requestTokenSecret = requestTokens[query.state].requestTokenSecret; delete requestTokens[query.state]; // Verify user authorized access and the oauth_token matches @@ -45,7 +56,7 @@ Oauth._requestHandlers['1'] = function (service, query, res) { // subsequent call to the `login` method will be immediate. // Get the access token for signing requests - oauthBinding.prepareAccessToken(query); + oauthBinding.prepareAccessToken(query, requestTokenSecret); // Run service-specific handler. var oauthResult = service.handleOauthRequest(oauthBinding); diff --git a/packages/oauth1/oauth1_tests.js b/packages/oauth1/oauth1_tests.js index d7eb0e1979..272e0cb123 100644 --- a/packages/oauth1/oauth1_tests.js +++ b/packages/oauth1/oauth1_tests.js @@ -40,7 +40,9 @@ Tinytest.add("oauth1 - loginResultForCredentialToken is stored", function (test) }); // simulate logging in using twitterfoo - OAuth1Test.requestTokens[credentialToken] = twitterfooAccessToken; + OAuth1Test.requestTokens[credentialToken] = { + requestToken: twitterfooAccessToken + }; var req = { method: "POST", From 9e0897aecd41109e71c8c148364b4f9bd1b4ccb6 Mon Sep 17 00:00:00 2001 From: Robert Lowe Date: Fri, 30 Aug 2013 16:56:53 -0400 Subject: [PATCH 109/335] Adds documentation for `config` arg of `OAuth1Binding`'s constructor Context: https://github.com/meteor/meteor/pull/1253#commitcomment-3980200 --- packages/oauth1/oauth1_binding.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/oauth1/oauth1_binding.js b/packages/oauth1/oauth1_binding.js index 59b6fdb5d4..c3af432318 100644 --- a/packages/oauth1/oauth1_binding.js +++ b/packages/oauth1/oauth1_binding.js @@ -4,7 +4,9 @@ var querystring = Npm.require("querystring"); // An OAuth1 wrapper around http calls which helps get tokens and // takes care of HTTP headers // -// @param config {Object} Keys, Secrets, etc +// @param config {Object} +// - consumerKey (String): oauth consumer key +// - secret (String): oauth consumer secret // @param urls {Object} // - requestToken (String): url // - authorize (String): url @@ -15,7 +17,6 @@ OAuth1Binding = function(config, urls) { this._urls = urls; }; - OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { var self = this; From 400778a55960f67de4178f0916fe166bd5666c26 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Wed, 25 Sep 2013 20:16:19 -0700 Subject: [PATCH 110/335] Cleanup. Whitespace, comments, style, unused variables. --- packages/oauth1/oauth1_binding.js | 22 ++++++++++++++-------- packages/oauth1/oauth1_server.js | 13 ++++++------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/oauth1/oauth1_binding.js b/packages/oauth1/oauth1_binding.js index c3af432318..bf9b88140d 100644 --- a/packages/oauth1/oauth1_binding.js +++ b/packages/oauth1/oauth1_binding.js @@ -27,9 +27,10 @@ OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { var response = self._call('POST', self._urls.requestToken, headers); var tokens = querystring.parse(response.content); - // XXX should we also store oauth_token_secret here? Yes, we should. if (!tokens.oauth_callback_confirmed) - throw new Error("oauth_callback_confirmed false when requesting oauth1 token", tokens); + throw new Error( + "oauth_callback_confirmed false when requesting oauth1 token", tokens); + self.requestToken = tokens.oauth_token; self.requestTokenSecret = tokens.oauth_token_secret; }; @@ -37,9 +38,14 @@ OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) { OAuth1Binding.prototype.prepareAccessToken = function(query, requestTokenSecret) { var self = this; - // support implemntations that use request token secrets + // support implementations that use request token secrets. This is + // read by self._call. + // + // XXX make it a param to call, not something stashed on self? It's + // kinda confusing right now, everything except this is passed as + // arguments, but this is stored. if (requestTokenSecret) - self.accessTokenSecret = requestTokenSecret + self.accessTokenSecret = requestTokenSecret; var headers = self._buildHeader({ oauth_token: query.oauth_token @@ -113,14 +119,14 @@ OAuth1Binding.prototype._getSignature = function(method, url, rawHeaders, access OAuth1Binding.prototype._call = function(method, url, headers, params, callback) { var self = this; - - // callback functions to support parameters/customization - if(typeof(url) == "function"){ + // all URLs to be functions to support parameters/customization + if(typeof url === "function") { url = url(self); } // Get the signature - headers.oauth_signature = self._getSignature(method, url, headers, self.accessTokenSecret, params); + headers.oauth_signature = + self._getSignature(method, url, headers, self.accessTokenSecret, params); // Make a authorization string according to oauth1 spec var authString = self._getAuthHeaderString(headers); diff --git a/packages/oauth1/oauth1_server.js b/packages/oauth1/oauth1_server.js index b4a6ab5f1c..6a61fbd6c7 100644 --- a/packages/oauth1/oauth1_server.js +++ b/packages/oauth1/oauth1_server.js @@ -1,6 +1,5 @@ // A place to store request tokens pending verification var requestTokens = {}; -var querystring = Npm.require("querystring"); OAuth1Test = {requestTokens: requestTokens}; @@ -29,7 +28,7 @@ Oauth._requestHandlers['1'] = function (service, query, res) { // support for scope/name parameters var redirectUrl = undefined; - if(typeof(urls.authenticate) == "function"){ + if(typeof urls.authenticate === "function") { redirectUrl = urls.authenticate(oauthBinding); } else { redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; @@ -44,7 +43,7 @@ Oauth._requestHandlers['1'] = function (service, query, res) { // token and access token secret and log in as user // Get the user's request token so we can verify it and clear it - var requestToken = requestTokens[query.state].requestToken; + var requestToken = requestTokens[query.state].requestToken; var requestTokenSecret = requestTokens[query.state].requestTokenSecret; delete requestTokens[query.state]; @@ -63,10 +62,10 @@ Oauth._requestHandlers['1'] = function (service, query, res) { // Add the login result to the result map Oauth._loginResultForCredentialToken[query.state] = { - serviceName: service.serviceName, - serviceData: oauthResult.serviceData, - options: oauthResult.options - }; + serviceName: service.serviceName, + serviceData: oauthResult.serviceData, + options: oauthResult.options + }; } // Either close the window, redirect, or render nothing From eda50d2d1ed65ff40f0b85f8dc089d2c50ff6dd9 Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Fri, 27 Sep 2013 14:38:47 -0700 Subject: [PATCH 111/335] add history.md note --- History.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/History.md b/History.md index 4154280e35..c8c64063d2 100644 --- a/History.md +++ b/History.md @@ -21,6 +21,9 @@ running on Linux machines with glibc 2.9 or newer (Ubuntu 10.04+, RHEL and CentOS 6+, Fedora 10+, Debian 6+). +* Support OAuth1 services that require request token secrets as well as + authentication token secrets. #1253 + ## v0.6.5.1 * Fix syntax errors on lines that end with a backslash. #1326 From db03f574a3f77ca73c3f74a6b025cc2d8efdd2b6 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 27 Sep 2013 14:46:10 -0700 Subject: [PATCH 112/335] Implement Collection.upsert(). Basically the same code as Collection.update(... { upsert: true }) except that it returns an object with numberAffected and insertedId keys. remove() is back to returning just the number affected, not an object, so that both update() and remove() match the mongo api. Untested. --- packages/minimongo/minimongo.js | 2 +- packages/mongo-livedata/collection.js | 26 ++++++++++++++++++++++--- packages/mongo-livedata/mongo_driver.js | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 59cd1be837..95941a7fdc 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -537,7 +537,7 @@ LocalCollection.prototype.remove = function (selector, callback) { LocalCollection._recomputeResults(query); }); self._observeQueue.drain(); - var result = { numberAffected: remove.length }; + var result = remove.length; if (callback) Meteor.defer(function () { callback(null, result); diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index ee6b36a434..77ea20637f 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -337,13 +337,24 @@ var throwIfSelectorIsNotId = function (selector, methodName) { // generating their result until the database has acknowledged // them. In the future maybe we should provide a flag to turn this // off. -_.each(["insert", "update", "remove"], function (name) { +_.each(["insert", "update", "remove", "upsert"], function (name) { Meteor.Collection.prototype[name] = function (/* arguments */) { var self = this; var args = _.toArray(arguments); var callback; var ret; + // Calling `Collection.upsert()` is just like calling `Collection.update()` + // with upsert: true, except that we return the whole object with + // `numberAffected` and `idInserted` keys. So we do the same thing as an + // update, except that we save `isUpsert` to determine what to return when + // we're done. + var isUpsert = false; + if (name === "upsert") { + isUpsert = true; + name = "update"; + } + if (args.length && args[args.length - 1] instanceof Function) callback = args.pop(); @@ -364,7 +375,9 @@ _.each(["insert", "update", "remove"], function (name) { args[0] = Meteor.Collection._rewriteSelector(args[0]); if (name === "update") { - var options = args[2]; + var options = _.clone(args[2]); + if (isUpsert) + options.upsert = true; if (options && options.upsert) { // set `insertedId` if absent. `insertedId` is a Meteor extension. if (options.insertedId) { @@ -427,8 +440,15 @@ _.each(["insert", "update", "remove"], function (name) { // On updates and removes, return whatever the collection returned; on // inserts, always return the id that we generated. If the user provided // a callback, then we expect queryRet to be undefined. - if (name !== "insert") + if (name !== "insert") { ret = queryRet; + // Upsert updates return an object with the number affected and the + // inserted id, but for update queries we only return the number + // affected to match the mongo api. Meteor.Collection.upsert() can be + // used to return the whole object. + if (name === "update" && ! isUpsert) + ret = ret.numberAffected; + } } catch (e) { if (callback) { callback(e); diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 8171ae0d17..149a0fc1bc 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -289,7 +289,7 @@ MongoConnection.prototype._remove = function (collection_name, selector, try { var collection = self._getCollection(collection_name); collection.remove(replaceTypes(selector, replaceMeteorAtomWithMongo), - {safe: true}, numberAffectedCallback(callback)); + {safe: true}, callback); } catch (e) { write.committed(); throw e; From ff143f94b77125cf5fd2fd743ffc637afa17ed66 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 27 Sep 2013 14:58:59 -0700 Subject: [PATCH 113/335] Account for undefined options --- packages/mongo-livedata/collection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 77ea20637f..75ade797d8 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -375,7 +375,7 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { args[0] = Meteor.Collection._rewriteSelector(args[0]); if (name === "update") { - var options = _.clone(args[2]); + var options = _.clone(args[2]) || {}; if (isUpsert) options.upsert = true; if (options && options.upsert) { From 3177d9ad416ae97a98a2b8c4b2b40a9fc03f6b9c Mon Sep 17 00:00:00 2001 From: Nick Martin Date: Thu, 26 Sep 2013 19:42:16 -0700 Subject: [PATCH 114/335] Use http_proxy environment variable so meteor update and deploy work behind an http proxy. --- History.md | 5 +++ tools/deploy-galaxy.js | 7 ++-- tools/deploy.js | 23 +++++----- tools/files.js | 53 ----------------------- tools/http-helpers.js | 95 ++++++++++++++++++++++++++++++++++++++++++ tools/meteor_npm.js | 3 +- tools/updater.js | 3 +- tools/warehouse.js | 9 ++-- 8 files changed, 125 insertions(+), 73 deletions(-) create mode 100644 tools/http-helpers.js diff --git a/History.md b/History.md index c8c64063d2..6c2d8769ba 100644 --- a/History.md +++ b/History.md @@ -17,6 +17,11 @@ 0.6.5. (A bug prevented the 0.6.5 reimplementation of `register_extension` from working properly anyway.) +* Support using an HTTP proxy in the `meteor` command line tool. This + allows the `update`, `deploy`, `logs`, and `mongo` commands to work + behind a proxy. Use the standard `http_proxy` environment variable to + specify your proxy endpoint. #429, #689, #1338 + * Build Linux binaries on an older Linux machine. Meteor now supports running on Linux machines with glibc 2.9 or newer (Ubuntu 10.04+, RHEL and CentOS 6+, Fedora 10+, Debian 6+). diff --git a/tools/deploy-galaxy.js b/tools/deploy-galaxy.js index 12efda1bb2..228fd616ec 100644 --- a/tools/deploy-galaxy.js +++ b/tools/deploy-galaxy.js @@ -5,7 +5,7 @@ var fs = require('fs'); var unipackage = require('./unipackage.js'); var fiberHelpers = require('./fiber-helpers.js'); var Fiber = require('fibers'); -var request = require('request'); +var httpHelpers = require('./http-helpers.js'); var _ = require('underscore'); // a bit of a hack @@ -90,7 +90,7 @@ exports.discoverGalaxy = function (app) { // At some point we may want to send a version in the request so that galaxy // can respond differently to different versions of meteor. - request({ + httpHelpers.request({ url: url, json: true, strictSSL: true, @@ -210,7 +210,8 @@ exports.deploy = function (options) { var fileSize = fs.statSync(starball).size; var fileStream = fs.createReadStream(starball); var future = new Future; - var req = request.put({ + var req = httpHelpers.request({ + method: "PUT", url: info.put, headers: { 'content-length': fileSize, 'content-type': 'application/octet-stream' }, diff --git a/tools/deploy.js b/tools/deploy.js index 90e00d0811..caf8ccc627 100644 --- a/tools/deploy.js +++ b/tools/deploy.js @@ -7,6 +7,7 @@ var qs = require('querystring'); var path = require('path'); var files = require('./files.js'); +var httpHelpers = require('./http-helpers.js'); var warehouse = require('./warehouse.js'); var buildmessage = require('./buildmessage.js'); var _ = require('underscore'); @@ -43,15 +44,16 @@ var meteor_rpc = function (rpc_name, method, site, query_params, callback) { url += '?' + qs.stringify(query_params); } - var request = require('request'); - var r = request({method: method, url: url}, function (error, response, body) { - if (error || ((response.statusCode !== 200) - && (response.statusCode !== 201))) - // pass some non-falsy error back to callback - callback(error || response.statusCode, body); - else - callback(null, body); - }); + var r = httpHelpers.request( + {method: method, url: url}, + function (error, response, body) { + if (error || ((response.statusCode !== 200) + && (response.statusCode !== 201))) + // pass some non-falsy error back to callback + callback(error || response.statusCode, body); + else + callback(null, body); + }); return r; }; @@ -345,8 +347,7 @@ var with_password = function (site, callback) { // Future.throw. Basically, what Future.wrap does. callback = inFiber(callback); - var request = require('request'); - request(check_url, function (error, response, body) { + httpHelpers.request(check_url, function (error, response, body) { if (error || response.statusCode !== 200) { callback(); diff --git a/tools/files.js b/tools/files.js index 935051ad60..e14b9a311c 100644 --- a/tools/files.js +++ b/tools/files.js @@ -436,59 +436,6 @@ _.extend(exports, { future.wait(); }, - // A synchronous wrapper around request(...) that returns the response "body" - // or throws. - getUrl: function (urlOrOptions, callback) { - var future = new Future; - // can't just use Future.wrap, because we want to return "body", not - // "response". - - urlOrOptions = _.clone(urlOrOptions); // we are going to change it - var appVersion; - try { - appVersion = getToolsVersion(); - } catch(e) { - appVersion = 'checkout'; - } - - // meteorReleaseContext - an option with information about app directory - // release versions, etc, is used to get exact Meteor version used. - if (urlOrOptions.hasOwnProperty('meteorReleaseContext')) { - // Get meteor app release version: if specified in command line args, take - // releaseVersion, if not specified, try global meteor version - var meteorReleaseContext = urlOrOptions.meteorReleaseContext; - appVersion = meteorReleaseContext.releaseVersion; - - if (appVersion === 'none') - appVersion = meteorReleaseContext.appReleaseVersion; - if (appVersion === 'none') - appVersion = 'checkout'; - - delete urlOrOptions.meteorReleaseContext; - } - - // Get some kind of User Agent: environment information. - var ua = util.format('Meteor/%s OS/%s (%s; %s; %s;)', - appVersion, os.platform(), os.type(), os.release(), os.arch()); - - var headers = {'User-Agent': ua }; - - if (_.isObject(urlOrOptions)) - urlOrOptions.headers = _.extend(headers, urlOrOptions.headers); - else - urlOrOptions = { url: urlOrOptions, headers: headers }; - - var request = require('request'); - request(urlOrOptions, function (error, response, body) { - if (error) - future.throw(new files.OfflineError(error)); - else if (response.statusCode >= 400 && response.statusCode < 600) - future.throw(response); - else - future.return(body); - }); - return future.wait(); - }, // Use this if you'd like to replace a directory with another directory as // close to atomically as possible. It's better than recursively deleting the diff --git a/tools/http-helpers.js b/tools/http-helpers.js new file mode 100644 index 0000000000..68e8279a56 --- /dev/null +++ b/tools/http-helpers.js @@ -0,0 +1,95 @@ +/// +/// utility functions for dealing with urls and http +/// + +var os = require('os'); +var util = require('util'); + +var _ = require('underscore'); +var request = require('request'); +var Future = require('fibers/future'); + +var files = require('./files.js'); + + +var httpHelpers = exports; +_.extend(exports, { + + // A wrapper around request that sets http proxy. + request: function (urlOrOptions, callback) { + + if (!_.isObject(urlOrOptions)) + urlOrOptions = { url: urlOrOptions }; + + var url = urlOrOptions.url; + + // try to get proxy from environment + var proxy = process.env.HTTP_PROXY || process.env.http_proxy || null; + // if we're going to an https url, try the https_proxy env variable first. + if (/^https/i.test(url)) { + proxy = process.env.HTTPS_PROXY || process.env.https_proxy || proxy; + } + if (proxy && !urlOrOptions.proxy) { + urlOrOptions.proxy = proxy; + } + + return request(urlOrOptions, callback); + }, + + + + // A synchronous wrapper around request(...) that returns the response "body" + // or throws. + getUrl: function (urlOrOptions, callback) { + var future = new Future; + // can't just use Future.wrap, because we want to return "body", not + // "response". + + urlOrOptions = _.clone(urlOrOptions); // we are going to change it + var appVersion; + try { + appVersion = files.getToolsVersion(); + } catch(e) { + appVersion = 'checkout'; + } + + // meteorReleaseContext - an option with information about app directory + // release versions, etc, is used to get exact Meteor version used. + if (urlOrOptions.hasOwnProperty('meteorReleaseContext')) { + // Get meteor app release version: if specified in command line args, take + // releaseVersion, if not specified, try global meteor version + var meteorReleaseContext = urlOrOptions.meteorReleaseContext; + appVersion = meteorReleaseContext.releaseVersion; + + if (appVersion === 'none') + appVersion = meteorReleaseContext.appReleaseVersion; + if (appVersion === 'none') + appVersion = 'checkout'; + + delete urlOrOptions.meteorReleaseContext; + } + + // Get some kind of User Agent: environment information. + var ua = util.format('Meteor/%s OS/%s (%s; %s; %s;)', + appVersion, os.platform(), os.type(), os.release(), os.arch()); + + var headers = {'User-Agent': ua }; + + if (_.isObject(urlOrOptions)) + urlOrOptions.headers = _.extend(headers, urlOrOptions.headers); + else + urlOrOptions = { url: urlOrOptions, headers: headers }; + + httpHelpers.request(urlOrOptions, function (error, response, body) { + if (error) + future.throw(new files.OfflineError(error)); + else if (response.statusCode >= 400 && response.statusCode < 600) + future.throw(response); + else + future.return(body); + }); + return future.wait(); + } + + +}); diff --git a/tools/meteor_npm.js b/tools/meteor_npm.js index 67fb840d33..b70a6c3ca2 100644 --- a/tools/meteor_npm.js +++ b/tools/meteor_npm.js @@ -10,6 +10,7 @@ var path = require('path'); var fs = require('fs'); var cleanup = require(path.join(__dirname, 'cleanup.js')); var files = require(path.join(__dirname, 'files.js')); +var httpHelpers = require('./http-helpers.js'); var buildmessage = require('./buildmessage.js'); var _ = require('underscore'); @@ -491,7 +492,7 @@ _.extend(exports, { // dependencies. `npm install` times out after more than a minute. _ensureConnected: function () { try { - files.getUrl("http://registry.npmjs.org"); + httpHelpers.getUrl("http://registry.npmjs.org"); } catch (e) { buildmessage.error("Can't install npm dependencies. " + "Are you connected to the internet?"); diff --git a/tools/updater.js b/tools/updater.js index 0369ba5ee9..368c95452c 100644 --- a/tools/updater.js +++ b/tools/updater.js @@ -6,6 +6,7 @@ var testingUpdater = false; var inFiber = require('./fiber-helpers.js').inFiber; var files = require('./files.js'); var warehouse = require('./warehouse.js'); +var httpHelpers = require('./http-helpers.js'); var manifestUrl = testingUpdater ? 'https://s3.amazonaws.com/com.meteor.static/test/update/manifest.json' @@ -21,7 +22,7 @@ exports.getManifest = function (context) { if (context) options.meteorReleaseContext = context; - return files.getUrl(options); + return httpHelpers.getUrl(options); }; exports.startUpdateChecks = function (context) { diff --git a/tools/warehouse.js b/tools/warehouse.js index 91e47b3311..d2ea01838e 100644 --- a/tools/warehouse.js +++ b/tools/warehouse.js @@ -28,6 +28,7 @@ var _ = require("underscore"); var files = require('./files.js'); var updater = require('./updater.js'); +var httpHelpers = require('./http-helpers.js'); var fiberHelpers = require('./fiber-helpers.js'); var logging = require('./logging.js'); @@ -235,7 +236,7 @@ _.extend(warehouse, { // after we're done writing packages if (!releaseAlreadyExists) { try { - releaseManifestText = files.getUrl( + releaseManifestText = httpHelpers.getUrl( WAREHOUSE_URLBASE + "/releases/" + releaseVersion + ".release.json"); } catch (e) { // just throw, if we're in the background anyway, or if this is the @@ -303,7 +304,7 @@ _.extend(warehouse, { // try getting the releases's notices. only blessed releases have one, so // if we can't find it just proceed. try { - var notices = files.getUrl( + var notices = httpHelpers.getUrl( WAREHOUSE_URLBASE + "/releases/" + releaseVersion + ".notices.json"); // Real notices are valid JSON. @@ -347,7 +348,7 @@ _.extend(warehouse, { "meteor-tools-" + toolsVersion + "-" + platform + ".tar.gz"; var toolsTarballPath = "/tools/" + toolsVersion + "/" + toolsTarballFilename; - var toolsTarball = files.getUrl({ + var toolsTarball = httpHelpers.getUrl({ url: WAREHOUSE_URLBASE + toolsTarballPath, encoding: null }); @@ -430,7 +431,7 @@ _.extend(warehouse, { "/" + version + "/" + name + '-' + version + "-" + platform + ".tar.gz"; - var tarball = files.getUrl({url: packageUrl, encoding: null}); + var tarball = httpHelpers.getUrl({url: packageUrl, encoding: null}); files.extractTarGz(tarball, packageDir); if (!dontWriteFreshFile) fs.writeFileSync(warehouse.getPackageFreshFile(name, version), ''); From bc3ea63752a7f7d94efd35c0265f34a9a646639b Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 27 Sep 2013 15:10:18 -0700 Subject: [PATCH 115/335] Make sure our mutated options object makes into the actual collection method --- packages/mongo-livedata/collection.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 75ade797d8..dd73418b35 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -375,7 +375,8 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { args[0] = Meteor.Collection._rewriteSelector(args[0]); if (name === "update") { - var options = _.clone(args[2]) || {}; + // Mutate args but copy the original options object. + var options = args[2] = _.clone(args[2]) || {}; if (isUpsert) options.upsert = true; if (options && options.upsert) { From 2b082db1a8a1d2d13bca11eec1c9e7c9b17658d6 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 14:55:46 -0700 Subject: [PATCH 116/335] adjust tests for API --- .../mongo-livedata/mongo_livedata_tests.js | 189 ++++++++++-------- 1 file changed, 110 insertions(+), 79 deletions(-) diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 277f26ec0e..ae44683cd4 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -203,12 +203,12 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on expectObserve('', function () { var count = coll.update({run: run, x: -1}, {$inc: {x: 2}}, {multi: true}); - test.equal(count, { numberAffected: 0 }); + test.equal(count, 0); }); expectObserve('c(3,0,1)c(6,1,4)', function () { var count = coll.update({run: run}, {$inc: {x: 2}}, {multi: true}); - test.equal(count, { numberAffected: 2 }); + test.equal(count, 2); test.equal(_.pluck(coll.find({run: run}, {sort: {x: -1}}).fetch(), "x"), [6, 3]); }); @@ -222,7 +222,7 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on expectObserve('r(13,1)', function () { var count = coll.remove({run: run, x: {$gt: 10}}); - test.equal(count, { numberAffected: 1 }); + test.equal(count, 1); test.equal(coll.find({run: run}).count(), 1); }); @@ -233,7 +233,7 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on expectObserve('', function () { var count = coll.remove({run: run}); - test.equal(count, { numberAffected: 0 }); + test.equal(count, 0); test.equal(coll.find({run: run}).count(), 0); }); @@ -820,12 +820,11 @@ if (Meteor.isServer) { coll.insert({ foo: "bar" }); coll.insert({ foo: "baz" }); - test.equal(coll.update({}, { $set: { foo: "qux" } }, { multi: true }), { - numberAffected: 2 - }); + test.equal(coll.update({}, { $set: { foo: "qux" } }, { multi: true }), + 2); coll.update({}, { $set: { foo: "quux" } }, { multi: true }, function (err, result) { test.isFalse(err); - test.equal(result, { numberAffected: 2 }); + test.equal(result, 2); onComplete(); }); }); @@ -836,12 +835,12 @@ if (Meteor.isServer) { coll.insert({ foo: "bar" }); coll.insert({ foo: "baz" }); - test.equal(coll.remove({}), { numberAffected: 2 }); + test.equal(coll.remove({}), 2); coll.insert({ foo: "bar" }); coll.insert({ foo: "baz" }); coll.remove({}, function (err, result) { test.isFalse(err); - test.equal(result, { numberAffected: 2 }); + test.equal(result, 2); onComplete(); }); }); @@ -918,86 +917,118 @@ if (Meteor.isServer) { } // end Meteor.isServer _.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { - Tinytest.addAsync("mongo-livedata - upsert " + (minimongo ? "minimongo" : "") + ", " + idGeneration, function (test, onComplete) { - var run = test.runId(); - var options = collectionOptions; - if (minimongo) - options = _.extend({}, collectionOptions, { connection: null }); - var coll = new Meteor.Collection("livedata_upsert_collection_"+run, options); + _.each([true, false], function (useUpdate) { + Tinytest.addAsync("mongo-livedata - " + (useUpdate ? "update " : "") + "upsert " + (minimongo ? "minimongo" : "") + ", " + idGeneration, function (test, onComplete) { + var upsert = function (query, mod, options) { + if (useUpdate) + return { numberAffected: + coll.update(query, mod, + _.extend({ upsert: true }, options)) }; + else + return coll.upsert(query, mod, options); + }; + var stripId = function (obj) { + delete obj._id; + }; + var compareResults = function (actual, expected) { + if (useUpdate) { + actual = _.map(actual, stripId); + expected = _.map(expected, stripId); + } + test.equal(actual, expected); + }; - var result1 = coll.update({foo: 'bar'}, {foo: 'bar'}, {upsert: true}); - test.equal(result1.numberAffected, 1); - test.isTrue(result1.insertedId); - test.equal(coll.find().fetch(), [{foo: 'bar', _id: result1.insertedId}]); + var run = test.runId(); + var options = collectionOptions; + if (minimongo) + options = _.extend({}, collectionOptions, { connection: null }); + var coll = new Meteor.Collection("livedata_upsert_collection_"+run, options); - var result2 = coll.update({foo: 'bar'}, {foo: 'baz'}, {upsert: true}); - test.equal(result2.numberAffected, 1); - test.isFalse(result2.insertedId); - test.equal(coll.find().fetch(), [{foo: 'baz', _id: result1.insertedId}]); + var result1 = upsert({foo: 'bar'}, {foo: 'bar'}); + test.equal(result1.numberAffected, 1); + if (! useUpdate) + test.isTrue(result1.insertedId); + compareResults(coll.find().fetch(), [{foo: 'bar', _id: result1.insertedId}]); - coll.remove({}); + var result2 = upsert({foo: 'bar'}, {foo: 'baz'}); + test.equal(result2.numberAffected, 1); + if (! useUpdate) + test.isFalse(result2.insertedId); + compareResults(coll.find().fetch(), [{foo: 'baz', _id: result1.insertedId}]); - // Test values that require transformation to go into Mongo: + coll.remove({}); - var t1 = new Meteor.Collection.ObjectID(); - var t2 = new Meteor.Collection.ObjectID(); - var result3 = coll.update({foo: t1}, {foo: t1}, {upsert: true}); - test.equal(result3.numberAffected, 1); - test.isTrue(result3.insertedId); - test.equal(coll.find().fetch(), [{foo: t1, _id: result3.insertedId}]); + // Test values that require transformation to go into Mongo: - var result4 = coll.update({foo: t1}, {foo: t2}, {upsert: true}); - test.equal(result2.numberAffected, 1); - test.isFalse(result2.insertedId); - test.equal(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); + var t1 = new Meteor.Collection.ObjectID(); + var t2 = new Meteor.Collection.ObjectID(); + var result3 = upsert({foo: t1}, {foo: t1}); + test.equal(result3.numberAffected, 1); + if (! useUpdate) + test.isTrue(result3.insertedId); + compareResults(coll.find().fetch(), [{foo: t1, _id: result3.insertedId}]); - coll.remove({}); + var result4 = upsert({foo: t1}, {foo: t2}); + test.equal(result2.numberAffected, 1); + if (! useUpdate) + test.isFalse(result2.insertedId); + compareResults(coll.find().fetch(), [{foo: t2, _id: result3.insertedId}]); - // Test modification + coll.remove({}); - var result5 = coll.update({name: 'David'}, {$set: {foo: 1}}, {upsert: true}); - test.equal(result5.numberAffected, 1); - test.isTrue(result5.insertedId); - var davidId = result5.insertedId; - test.equal(coll.find().fetch(), [{name: 'David', foo: 1, _id: davidId}]); + // Test modification - test.throws(function () { - // test that bad modifier fails fast - coll.update({name: 'David'}, {$blah: {foo: 2}}, {upsert: true}); + var result5 = upsert({name: 'David'}, {$set: {foo: 1}}); + test.equal(result5.numberAffected, 1); + if (! useUpdate) + test.isTrue(result5.insertedId); + var davidId = result5.insertedId; + compareResults(coll.find().fetch(), [{name: 'David', foo: 1, _id: davidId}]); + + test.throws(function () { + // test that bad modifier fails fast + upsert({name: 'David'}, {$blah: {foo: 2}}); + }); + + + var result6 = upsert({name: 'David'}, {$set: {foo: 2}}); + test.equal(result6.numberAffected, 1); + if (! useUpdate) + test.isFalse(result6.insertedId); + compareResults(coll.find().fetch(), [{name: 'David', foo: 2, + _id: result5.insertedId}]); + + var emilyId = coll.insert({name: 'Emily', foo: 2}); + compareResults(coll.find().fetch(), [{name: 'David', foo: 2, _id: davidId}, + {name: 'Emily', foo: 2, _id: emilyId}]); + + // multi update by upsert + var result7 = upsert({foo: 2}, + {$set: {bar: 7}, + $setOnInsert: {name: 'Fred', foo: 2}}, + {multi: true}); + test.equal(result7.numberAffected, 2); + if (! useUpdate) + test.isFalse(result7.insertedId); + compareResults(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}]); + + // insert by multi upsert + var result8 = upsert({foo: 3}, + {$set: {bar: 7}, + $setOnInsert: {name: 'Fred', foo: 2}}, + {multi: true}); + test.equal(result8.numberAffected, 1); + if (! useUpdate) + test.isTrue(result8.insertedId); + var fredId = result8.insertedId; + compareResults(coll.find().fetch(), + [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, + {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); + + onComplete(); }); - - - var result6 = coll.update({name: 'David'}, {$set: {foo: 2}}, {upsert: true}); - test.equal(result6.numberAffected, 1); - test.isFalse(result6.insertedId); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, - _id: result5.insertedId}]); - - var emilyId = coll.insert({name: 'Emily', foo: 2}); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, _id: davidId}, - {name: 'Emily', foo: 2, _id: emilyId}]); - - // multi update by upsert - var result7 = coll.update({foo: 2}, - {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, - {upsert: true, multi: true}); - test.equal(result7.numberAffected, 2); - test.isFalse(result7.insertedId); - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, - {name: 'Emily', foo: 2, bar: 7, _id: emilyId}]); - - // insert by multi upsert - var result8 = coll.update({foo: 3}, - {$set: {bar: 7}, $setOnInsert: {name: 'Fred', foo: 2}}, - {upsert: true, multi: true}); - test.equal(result8.numberAffected, 1); - test.isTrue(result8.insertedId); - var fredId = result8.insertedId; - test.equal(coll.find().fetch(), [{name: 'David', foo: 2, bar: 7, _id: davidId}, - {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, - {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); - - onComplete(); }); }); From e56428dceba6d51f3ce6aaaef552e1eff8dcef6b Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 27 Sep 2013 16:06:28 -0700 Subject: [PATCH 117/335] Make sure we provide the right return value for callbacks also. Also, don't mutate `name` to "update" from "upsert"; the function closes over it so all subsequent calls will start with name "update". --- packages/mongo-livedata/collection.js | 62 ++++++++++++++------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index dd73418b35..32cbca3dd5 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -343,6 +343,7 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { var args = _.toArray(arguments); var callback; var ret; + var methodName = name; // Calling `Collection.upsert()` is just like calling `Collection.update()` // with upsert: true, except that we return the whole object with @@ -350,15 +351,15 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { // update, except that we save `isUpsert` to determine what to return when // we're done. var isUpsert = false; - if (name === "upsert") { + if (methodName === "upsert") { isUpsert = true; - name = "update"; + methodName = "update"; } if (args.length && args[args.length - 1] instanceof Function) callback = args.pop(); - if (name === "insert") { + if (methodName === "insert") { if (!args.length) throw new Error("insert requires an argument"); // shallow-copy the document and generate an ID @@ -374,7 +375,7 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { } else { args[0] = Meteor.Collection._rewriteSelector(args[0]); - if (name === "update") { + if (methodName === "update") { // Mutate args but copy the original options object. var options = args[2] = _.clone(args[2]) || {}; if (isUpsert) @@ -392,17 +393,28 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { } } + // On inserts, always return the id that we generated. On updates and + // removes, return the number of documents we affected. On upsert(), return + // the whole object that the collection returns on update (with the number + // affected and insertedId). + var transformResultFromCollection = function (result) { + if (methodName === "insert") { + return ret; + } else if (methodName === "update" && ! isUpsert) { + if (result) + return result.numberAffected; + else + return undefined; + } else { + return result; + } + }; + var wrappedCallback; if (callback) { - if (name === "insert") { - // On inserts, always return the id that we generated. - wrappedCallback = function (error, result) { - callback(error, !error && ret); - }; - } else { - // For updates and removes, return whatever the collection returned. - wrappedCallback = callback; - } + wrappedCallback = function (error, result) { + callback(error, ! error && transformResultFromCollection(result)); + }; } if (self._connection && self._connection !== Meteor.server) { @@ -417,39 +429,29 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { // down. wrappedCallback = function (err) { if (err) - Meteor._debug(name + " failed: " + (err.reason || err.stack)); + Meteor._debug(methodName + " failed: " + (err.reason || err.stack)); }; } var enclosing = DDP._CurrentInvocation.get(); var alreadyInSimulation = enclosing && enclosing.isSimulation; - if (!alreadyInSimulation && name !== "insert") { + if (!alreadyInSimulation && methodName !== "insert") { // If we're about to actually send an RPC, we should throw an error if // this is a non-ID selector, because the mutation methods only allow // single-ID selectors. (If we don't throw here, we'll see flicker.) - throwIfSelectorIsNotId(args[0], name); + throwIfSelectorIsNotId(args[0], methodName); } - self._connection.apply(self._prefix + name, args, wrappedCallback); + self._connection.apply(self._prefix + methodName, args, wrappedCallback); } else { // it's my collection. descend into the collection object // and propagate any exception. args.push(wrappedCallback); try { - var queryRet = self._collection[name].apply(self._collection, args); - // On updates and removes, return whatever the collection returned; on - // inserts, always return the id that we generated. If the user provided - // a callback, then we expect queryRet to be undefined. - if (name !== "insert") { - ret = queryRet; - // Upsert updates return an object with the number affected and the - // inserted id, but for update queries we only return the number - // affected to match the mongo api. Meteor.Collection.upsert() can be - // used to return the whole object. - if (name === "update" && ! isUpsert) - ret = ret.numberAffected; - } + // If the user provided a callback, then we expect queryRet to be undefined. + var queryRet = self._collection[methodName].apply(self._collection, args); + ret = transformResultFromCollection(queryRet); } catch (e) { if (callback) { callback(e); From 2b4d4974fa1a54afa74886a98b1f832138c3ed64 Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 27 Sep 2013 16:07:55 -0700 Subject: [PATCH 118/335] Update minimongo tests to new api. All tests pass now, but we've got the unfortunate situation that the LocalCollection return values don't match the Meteor.Collection return values, which is problematic because the minimongo tests use LocalCollection directly. Hmm. --- packages/minimongo/minimongo_tests.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index a996207b0c..8df21830bf 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -105,11 +105,11 @@ Tinytest.add("minimongo - basics", function (test) { c.remove({_id: false}); c.remove({_id: undefined}); count = c.remove(); - test.equal(count, { numberAffected: 0 }); + test.equal(count, 0); test.equal(c.find().count(), 4); count = c.remove({}); - test.equal(count, { numberAffected: 4 }); + test.equal(count, 4); test.equal(c.find().count(), 0); c.insert({_id: 1, name: "strawberry", tags: ["fruit", "red", "squishy"]}); From d4d7ebb78318ff74a45d5c08662ca08091654560 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 23 Sep 2013 16:35:37 -0700 Subject: [PATCH 119/335] Implements ES5-style callbacks for cursor forEach and map. Fixes #63. Based on iwoj's PR. Needs tests and docs. --- packages/minimongo/minimongo.js | 14 +++--- packages/mongo-livedata/mongo_driver.js | 60 ++++++++++++++----------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 7fb03f7810..7a00b71e2d 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -140,9 +140,8 @@ LocalCollection.prototype.findOne = function (selector, options) { return this.find(selector, options).fetch()[0]; }; -LocalCollection.Cursor.prototype.forEach = function (callback) { +LocalCollection.Cursor.prototype.forEach = function (callback, thisArg) { var self = this; - var doc; if (self.db_objects === null) self.db_objects = self._getRawObjects(true); @@ -155,12 +154,13 @@ LocalCollection.Cursor.prototype.forEach = function (callback) { movedBefore: true}); while (self.cursor_pos < self.db_objects.length) { - var elt = EJSON.clone(self.db_objects[self.cursor_pos++]); + var elt = EJSON.clone(self.db_objects[self.cursor_pos]); if (self.projection_f) elt = self.projection_f(elt); if (self._transform) elt = self._transform(elt); - callback(elt); + callback.call(thisArg, elt, self.cursor_pos, self); + ++self.cursor_pos; } }; @@ -169,11 +169,11 @@ LocalCollection.Cursor.prototype.getTransform = function () { return self._transform; }; -LocalCollection.Cursor.prototype.map = function (callback) { +LocalCollection.Cursor.prototype.map = function (callback, thisArg) { var self = this; var res = []; - self.forEach(function (doc) { - res.push(callback(doc)); + self.forEach(function (doc, index) { + res.push(callback.call(thisArg, doc, index, self)); }); return res; }; diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index deda9b9d48..aaf9d3022f 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -437,9 +437,15 @@ _.each(['forEach', 'map', 'rewind', 'fetch', 'count'], function (method) { if (self._cursorDescription.options.tailable) throw new Error("Cannot call " + method + " on a tailable cursor"); - if (!self._synchronousCursor) + if (!self._synchronousCursor) { self._synchronousCursor = self._mongo._createSynchronousCursor( - self._cursorDescription, true); + self._cursorDescription, { + // Make sure that the "self" argument to forEach/map callbacks is the + // Cursor, not the SynchronousCursor. + selfForIteration: self, + useTransform: true + }); + } return self._synchronousCursor[method].apply( self._synchronousCursor, arguments); @@ -481,20 +487,21 @@ Cursor.prototype.observeChanges = function (callbacks) { self._cursorDescription, ordered, callbacks); }; -MongoConnection.prototype._createSynchronousCursor = function(cursorDescription, - useTransform) { +MongoConnection.prototype._createSynchronousCursor = function( + cursorDescription, options) { var self = this; + options = _.pick(options || {}, 'selfForIteration', 'useTransform'); var collection = self._getCollection(cursorDescription.collectionName); - var options = cursorDescription.options; + var cursorOptions = cursorDescription.options; var mongoOptions = { - sort: options.sort, - limit: options.limit, - skip: options.skip + sort: cursorOptions.sort, + limit: cursorOptions.limit, + skip: cursorOptions.skip }; // Do we want a tailable cursor (which only works on capped collections)? - if (options.tailable) { + if (cursorOptions.tailable) { // We want a tailable cursor... mongoOptions.tailable = true; // ... and for the server to wait a bit if any getMore has no data (rather @@ -507,16 +514,21 @@ MongoConnection.prototype._createSynchronousCursor = function(cursorDescription, var dbCursor = collection.find( replaceTypes(cursorDescription.selector, replaceMeteorAtomWithMongo), - options.fields, mongoOptions); + cursorOptions.fields, mongoOptions); - return new SynchronousCursor(dbCursor, cursorDescription, useTransform); + return new SynchronousCursor(dbCursor, cursorDescription, options); }; -var SynchronousCursor = function (dbCursor, cursorDescription, useTransform) { +var SynchronousCursor = function (dbCursor, cursorDescription, options) { var self = this; + options = _.pick(options || {}, 'selfForIteration', 'useTransform'); + self._dbCursor = dbCursor; self._cursorDescription = cursorDescription; - if (useTransform && cursorDescription.options.transform) { + // The "self" argument passed to forEach/map callbacks. If we're wrapped + // inside a user-visible Cursor, we want to provide the outer cursor! + self._selfForIteration = options.selfForIteration || self; + if (options.useTransform && cursorDescription.options.transform) { self._transform = Deps._makeNonreactive( cursorDescription.options.transform ); @@ -558,29 +570,26 @@ _.extend(SynchronousCursor.prototype, { } }, - // XXX Make more like ECMA forEach: - // https://github.com/meteor/meteor/pull/63#issuecomment-5320050 - forEach: function (callback) { + forEach: function (callback, thisArg) { var self = this; // We implement the loop ourself instead of using self._dbCursor.each, // because "each" will call its callback outside of a fiber which makes it // much more complex to make this function synchronous. + var index = 0; while (true) { var doc = self._nextObject(); if (!doc) return; - callback(doc); + callback.call(thisArg, doc, index++, self._selfForIteration); } }, - // XXX Make more like ECMA map: - // https://github.com/meteor/meteor/pull/63#issuecomment-5320050 // XXX Allow overlapping callback executions if callback yields. - map: function (callback) { + map: function (callback, thisArg) { var self = this; var res = []; - self.forEach(function (doc) { - res.push(callback(doc)); + self.forEach(function (doc, index) { + res.push(callback.call(thisArg, doc, index, self._selfForIteration)); }); return res; }, @@ -892,7 +901,7 @@ _.extend(LiveResultsSet.prototype, { self._synchronousCursor.rewind(); } else { self._synchronousCursor = self._mongoHandle._createSynchronousCursor( - self._cursorDescription, false /* !useTransform */); + self._cursorDescription); } var newResults = self._synchronousCursor.getRawObjects(self._ordered); var oldResults = self._results; @@ -1020,8 +1029,7 @@ MongoConnection.prototype._observeChangesTailable = function ( + " tailable cursor without a " + (ordered ? "addedBefore" : "added") + " callback"); } - var cursor = self._createSynchronousCursor(cursorDescription, - false /* useTransform */); + var cursor = self._createSynchronousCursor(cursorDescription); var stopped = false; var lastTS = undefined; @@ -1063,7 +1071,7 @@ MongoConnection.prototype._observeChangesTailable = function ( cursor = self._createSynchronousCursor(new CursorDescription( cursorDescription.collectionName, newSelector, - cursorDescription.options), false /* useTransform */); + cursorDescription.options)); } } }); From 3e1afb78501dcade1a57c71d7acc821939ee8122 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 23 Sep 2013 17:44:34 -0700 Subject: [PATCH 120/335] Tests for extra forEach/map arguments. --- packages/minimongo/minimongo_tests.js | 15 ++++++++++++--- .../mongo-livedata/mongo_livedata_tests.js | 19 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 5f19e703e0..6248c99825 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -180,16 +180,25 @@ Tinytest.add("minimongo - cursors", function (test) { // forEach var count = 0; - q.forEach(function (obj) { + var context = {}; + q.forEach(function (obj, i, cursor) { test.equal(obj.i, count++); - }); + test.equal(obj.i, i); + test.isTrue(context === this); + test.isTrue(cursor === q); + }, context); test.equal(count, 20); // everything empty test.length(q.fetch(), 0); q.rewind(); // map - res = q.map(function (obj) { return obj.i * 2; }); + res = q.map(function (obj, i, cursor) { + test.equal(obj.i, i); + test.isTrue(context === this); + test.isTrue(cursor === q); + return obj.i * 2; + }, context); test.length(res, 20); for (var i = 0; i < 20; i++) test.equal(res[i], i * 2); diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 64a23991ee..4b8a75fdf8 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -176,7 +176,12 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on var cur = coll.find({run: run}, {sort: ["x"]}); var total = 0; - cur.forEach(function (doc) { + var index = 0; + var context = {}; + cur.forEach(function (doc, i, cursor) { + test.equal(i, index++); + test.isTrue(cursor === cur); + test.isTrue(context === this); total *= 10; if (Meteor.isServer) { // Verify that the callbacks from forEach run sequentially and that @@ -190,13 +195,19 @@ Tinytest.addAsync("mongo-livedata - basics, " + idGeneration, function (test, on total += doc.x; // verify the meteor environment is set up here coll2.insert({total:total}); - }); + }, context); test.equal(total, 14); cur.rewind(); - test.equal(cur.map(function (doc) { + index = 0; + test.equal(cur.map(function (doc, i, cursor) { + // XXX we could theoretically make map run its iterations in parallel or + // something which would make this fail + test.equal(i, index++); + test.isTrue(cursor === cur); + test.isTrue(context === this); return doc.x * 2; - }), [2, 8]); + }, context), [2, 8]); test.equal(_.pluck(coll.find({run: run}, {sort: {x: -1}}).fetch(), "x"), [4, 1]); From 7f490a4081c70fe504567826a3b4884b691e21c8 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 24 Sep 2013 16:08:40 -0700 Subject: [PATCH 121/335] Docs for ES5-style iteration. --- docs/client/api.html | 4 ++++ docs/client/api.js | 14 ++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/client/api.html b/docs/client/api.html index e3f96a1f90..9fa58c4fbb 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -965,6 +965,8 @@ cursor, use [`forEach`](#foreach), [`map`](#map), or [`fetch`](#fetch). {{> api_box cursor_foreach}} +This interface is compatible with [Array.forEach](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). + When called from a reactive computation, `forEach` registers dependencies on the matching documents. @@ -980,6 +982,8 @@ Examples: {{> api_box cursor_map}} +This interface is compatible with [Array.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + When called from a reactive computation, `map` registers dependencies on the matching documents. diff --git a/docs/client/api.js b/docs/client/api.js index 2ab15f343e..4a7402824a 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -675,25 +675,31 @@ Template.api.cursor_fetch = { Template.api.cursor_foreach = { id: "foreach", - name: "cursor.forEach(callback)", + name: "cursor.forEach(callback, [thisArg])", locus: "Anywhere", descr: ["Call `callback` once for each matching document, sequentially and synchronously."], args: [ {name: "callback", type: "Function", - descr: "Function to call."} + descr: "Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself."}, + {name: "thisArg", + type: "Any", + descr: "An object which will be the value of `this` inside `callback`."} ] }; Template.api.cursor_map = { id: "map", - name: "cursor.map(callback)", + name: "cursor.map(callback, [thisArg])", locus: "Anywhere", descr: ["Map callback over all matching documents. Returns an Array."], args: [ {name: "callback", type: "Function", - descr: "Function to call."} + descr: "Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself."}, + {name: "thisArg", + type: "Any", + descr: "An object which will be the value of `this` inside `callback`."} ] }; From b86ba7e3dad23411559a29ebab4c678ae97f393c Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 24 Sep 2013 16:11:29 -0700 Subject: [PATCH 122/335] History.md update for forEach/map arguments. --- History.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/History.md b/History.md index 6c2d8769ba..e502bdc762 100644 --- a/History.md +++ b/History.md @@ -3,6 +3,9 @@ * `restrictCreationByEmail` option in `Accounts.config` to restrict new users to emails of specific domain (eg. only users with @meteor.com emails). +* Pass an index and the cursor itself to the callbacks in `cursor.forEach` and + `cursor.map`, just like the corresponding `Array` methods. #63 + * Better error when passing a string to {{#each}}. #722 * Write dates to Mongo as ISODate rather than Integer; existing data can be From 6494ec563fba223c3f168019d9fd459600e511fc Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Fri, 27 Sep 2013 18:50:39 -0700 Subject: [PATCH 123/335] Put .upsert() on LocalCollection and mongo driver. This way LocalCollection and Meteor.Collection have the same API. --- packages/minimongo/minimongo.js | 24 ++++++-- packages/minimongo/minimongo_tests.js | 4 +- packages/mongo-livedata/collection.js | 48 +++++----------- packages/mongo-livedata/mongo_driver.js | 55 +++++++++++++------ .../mongo-livedata/mongo_livedata_tests.js | 1 - .../remote_collection_driver.js | 2 +- 6 files changed, 75 insertions(+), 59 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 95941a7fdc..b78d873b3d 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -599,11 +599,17 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { updateCount = 1; } - var result = { - numberAffected: updateCount - }; - if (insertedId !== undefined) - result.insertedId = insertedId; + var result; + if (options.returnObject) { + result = { + numberAffected: updateCount + }; + if (insertedId !== undefined) + result.insertedId = insertedId; + } else { + result = updateCount; + } + if (callback) Meteor.defer(function () { callback(null, result); @@ -611,6 +617,14 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { return result; }; +LocalCollection.prototype.upsert = function (selector, mod, options, callback) { + var self = this; + return self.update(selector, mod, _.extend({}, options, { + upsert: true, + returnObject: true + }, callback)); +}; + LocalCollection.prototype._modifyAndNotify = function ( doc, mod, recomputeQids) { var self = this; diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 8df21830bf..343e7f198c 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -89,7 +89,7 @@ Tinytest.add("minimongo - basics", function (test) { test.length(c.find({type: "cryptographer"}).fetch(), 2); count = c.update({name: "snookums"}, {$set: {type: "cryptographer"}}); - test.equal(count, { numberAffected: 1 }); + test.equal(count, 1); test.equal(c.find().count(), 4); test.equal(c.find({type: "kitten"}).count(), 1); test.equal(c.find({type: "cryptographer"}).count(), 3); @@ -2016,7 +2016,7 @@ Tinytest.add("minimongo - saveOriginals", function (test) { c.update('bar', {$set: {k: 7}}); // update same doc twice // Verify returned count is correct - test.equal(count, { numberAffected: 2 }); + test.equal(count, 2); // Verify the originals. var originals = c.retrieveOriginals(); diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 32cbca3dd5..7f95b7c5df 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -343,23 +343,15 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { var args = _.toArray(arguments); var callback; var ret; - var methodName = name; - // Calling `Collection.upsert()` is just like calling `Collection.update()` - // with upsert: true, except that we return the whole object with - // `numberAffected` and `idInserted` keys. So we do the same thing as an - // update, except that we save `isUpsert` to determine what to return when - // we're done. - var isUpsert = false; - if (methodName === "upsert") { - isUpsert = true; - methodName = "update"; - } + var isUpdateOrUpsert = (name === "update" || name === "upsert"); + var isUpsert = (name === "update" && options && options.upsert) || + (name === "upsert"); if (args.length && args[args.length - 1] instanceof Function) callback = args.pop(); - if (methodName === "insert") { + if (name === "insert") { if (!args.length) throw new Error("insert requires an argument"); // shallow-copy the document and generate an ID @@ -375,12 +367,10 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { } else { args[0] = Meteor.Collection._rewriteSelector(args[0]); - if (methodName === "update") { + if (isUpdateOrUpsert) { // Mutate args but copy the original options object. var options = args[2] = _.clone(args[2]) || {}; - if (isUpsert) - options.upsert = true; - if (options && options.upsert) { + if (isUpsert) { // set `insertedId` if absent. `insertedId` is a Meteor extension. if (options.insertedId) { if (!(typeof options.insertedId === 'string' @@ -393,21 +383,13 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { } } - // On inserts, always return the id that we generated. On updates and - // removes, return the number of documents we affected. On upsert(), return - // the whole object that the collection returns on update (with the number - // affected and insertedId). + // On inserts, always return the id that we generated; on all other + // operations, just return the result from the collection. var transformResultFromCollection = function (result) { - if (methodName === "insert") { + if (name === "insert") return ret; - } else if (methodName === "update" && ! isUpsert) { - if (result) - return result.numberAffected; - else - return undefined; - } else { + else return result; - } }; var wrappedCallback; @@ -429,20 +411,20 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { // down. wrappedCallback = function (err) { if (err) - Meteor._debug(methodName + " failed: " + (err.reason || err.stack)); + Meteor._debug(name + " failed: " + (err.reason || err.stack)); }; } var enclosing = DDP._CurrentInvocation.get(); var alreadyInSimulation = enclosing && enclosing.isSimulation; - if (!alreadyInSimulation && methodName !== "insert") { + if (!alreadyInSimulation && name !== "insert") { // If we're about to actually send an RPC, we should throw an error if // this is a non-ID selector, because the mutation methods only allow // single-ID selectors. (If we don't throw here, we'll see flicker.) - throwIfSelectorIsNotId(args[0], methodName); + throwIfSelectorIsNotId(args[0], name); } - self._connection.apply(self._prefix + methodName, args, wrappedCallback); + self._connection.apply(self._prefix + name, args, wrappedCallback); } else { // it's my collection. descend into the collection object @@ -450,7 +432,7 @@ _.each(["insert", "update", "remove", "upsert"], function (name) { args.push(wrappedCallback); try { // If the user provided a callback, then we expect queryRet to be undefined. - var queryRet = self._collection[methodName].apply(self._collection, args); + var queryRet = self._collection[name].apply(self._collection, args); ret = transformResultFromCollection(queryRet); } catch (e) { if (callback) { diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index 149a0fc1bc..e680b1cd79 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -257,16 +257,6 @@ MongoConnection.prototype._refresh = function (collectionName, selector) { } }; -var numberAffectedCallback = function (callback) { - return Meteor.bindEnvironment(function (err, numberAffected) { - callback && callback(err, ! err && { - numberAffected: numberAffected - }); - }, function (err) { - Meteor._debug("Error in Mongo write:", err.stack); - }); -}; - MongoConnection.prototype._remove = function (collection_name, selector, callback) { var self = this; @@ -346,10 +336,20 @@ MongoConnection.prototype._update = function (collection_name, selector, mod, options.insertedId) { mongoOpts.insertedId = options.insertedId; simulateUpsertWithInsertedId(collection, mongoSelector, mongoMod, - isModify, mongoOpts, callback); + isModify, mongoOpts, function (err, result) { + // If we got here via a upsert() call, then + // we should return the whole + // object. Otherwise, we should just return + // the number of affected docs to match the + // mongo API. + if (result && ! options.returnObject) + callback(err, result.numberAffected); + else + callback(err, result); + }); } else { - collection.update(mongoSelector, mongoMod, mongoOpts, - numberAffectedCallback(callback)); + // For non-upserts, just return the number of affected documents. + collection.update(mongoSelector, mongoMod, mongoOpts, callback); } } catch (e) { write.committed(); @@ -364,13 +364,20 @@ var isModificationMod = function (mod) { return false; }; +// Assumes callback has already been wrapped with bindEnvironment. +var numberAffectedCallback = function (callback) { + return function (err, result) { + callback(err, ! err && { numberAffected: result }); + }; +}; + var NUM_OPTIMISTIC_TRIES = 3; var simulateUpsertWithInsertedId = function (collection, selector, mod, isModify, options, callback) { var insertedId = options.insertedId; // must exist - var mongoOptsForUpdate = _.extend({}, options); + var mongoOptsForUpdate = _.extend({}, options, { returnObject: true }); delete mongoOptsForUpdate.insertedId; delete mongoOptsForUpdate.upsert; @@ -426,7 +433,7 @@ var simulateUpsertWithInsertedId = function (collection, selector, mod, newDoc = mod; } - var mongoOptsForInsert = _.extend({}, options); + var mongoOptsForInsert = _.extend({}, options, { returnObject: true }); delete mongoOptsForUpdate.insertedId; mongoOptsForInsert.upsert = true; delete mongoOptsForInsert.multi; @@ -444,8 +451,7 @@ var simulateUpsertWithInsertedId = function (collection, selector, mod, Meteor._debug(err); callback(err); } else { - callback(null, _.extend(result, - { insertedId: insertedId })); + callback(null, _.extend(result, { insertedId: insertedId })); } })); }; @@ -460,6 +466,21 @@ _.each(["insert", "update", "remove"], function (method) { }; }); +MongoConnection.prototype.upsert = function (collectionName, selector, mod, + options, callback) { + var self = this; + if (typeof options === "function" && ! callback) { + callback = options; + options = {}; + } + + return self.update(collectionName, selector, mod, + _.extend({}, options, { + upsert: true, + returnObject: true + }, callback)); +}; + MongoConnection.prototype.find = function (collectionName, selector, options) { var self = this; diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index ae44683cd4..e473b1cc0c 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -50,7 +50,6 @@ EJSON.addType("dog", function (o) { return new Dog(o.name, o.color, o.actions);} // Parameterize tests. _.each( ['STRING', 'MONGO'], function(idGeneration) { - var collectionOptions = { idGeneration: idGeneration}; testAsyncMulti("mongo-livedata - database error reporting. " + idGeneration, [ diff --git a/packages/mongo-livedata/remote_collection_driver.js b/packages/mongo-livedata/remote_collection_driver.js index 9c155ca698..b32f82b0d3 100644 --- a/packages/mongo-livedata/remote_collection_driver.js +++ b/packages/mongo-livedata/remote_collection_driver.js @@ -9,7 +9,7 @@ _.extend(MongoInternals.RemoteCollectionDriver.prototype, { var ret = {}; _.each( ['find', 'findOne', 'insert', 'update', 'remove', '_ensureIndex', - '_dropIndex', '_createCappedCollection'], + '_dropIndex', '_createCappedCollection', 'upsert'], function (m) { ret[m] = _.bind(self.mongo[m], self.mongo, name); }); From 3e87c8f9d556351bd0e6ead91078ded4e485ced3 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 17:53:06 -0700 Subject: [PATCH 124/335] finish optimistic retry code detect collision --- packages/mongo-livedata/mongo_driver.js | 16 +++++++++--- .../mongo-livedata/mongo_livedata_tests.js | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index e680b1cd79..f0a5bcc8df 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -373,6 +373,13 @@ var numberAffectedCallback = function (callback) { var NUM_OPTIMISTIC_TRIES = 3; +// exposed for testing +MongoConnection._isCannotChangeIdError = function (err) { + // either of these checks should work, but just to be safe... + return (err.code === 13596 || + err.err.indexOf("cannot change _id of a document") === 0); +}; + var simulateUpsertWithInsertedId = function (collection, selector, mod, isModify, options, callback) { var insertedId = options.insertedId; // must exist @@ -445,11 +452,14 @@ var simulateUpsertWithInsertedId = function (collection, selector, mod, collection.update(selector, replacementWithId, mongoOptsForInsert, numberAffectedCallback(function (err, result) { if (err) { - // XXX figure out if this is a + // figure out if this is a // "cannot change _id of document" error, and // if so, try doUpdate() again, up to 3 times. - Meteor._debug(err); - callback(err); + if (MongoConnection._isCannotChangeIdError(err)) { + doUpdate(); + } else { + callback(err); + } } else { callback(null, _.extend(result, { insertedId: insertedId })); } diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index e473b1cc0c..0f9f5da7d9 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -913,6 +913,32 @@ if (Meteor.isServer) { onComplete(); }); + Tinytest.addAsync("mongo-livedata - upsert error parse, " + idGeneration, function (test, onComplete) { + var run = test.runId(); + var coll = new Meteor.Collection("livedata_upsert_errorparse_collection_"+run, collectionOptions); + + coll.insert({_id: 'foobar'}); + var err; + try { + coll.update({_id: 'foobar'}, {_id: 'cowbar'}); + } catch (e) { + err = e; + } + test.isTrue(err); + test.isTrue(MongoInternals.Connection._isCannotChangeIdError(err)); + + try { + coll.insert({_id: 'foobar'}); + } catch (e) { + err = e; + } + test.isTrue(err); + // duplicate id error is not same as change id error + test.isFalse(MongoInternals.Connection._isCannotChangeIdError(err)); + + onComplete(); + }); + } // end Meteor.isServer _.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { From 2724bd0d84c27952f43f67c10c352752c02c1529 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 18:53:23 -0700 Subject: [PATCH 125/335] upsert bugfixes and tests * mongo_driver native upsert returns insertedId * minimongo doesn't use query _id on upsert/replace * minimongo supports insertedId option * tests for all this --- packages/minimongo/minimongo.js | 2 + packages/minimongo/modify.js | 6 +- packages/mongo-livedata/mongo_driver.js | 29 ++++- .../mongo-livedata/mongo_livedata_tests.js | 118 +++++++++++++++++- 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index b78d873b3d..cb7b0fd5d6 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -595,6 +595,8 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { if (updateCount === 0 && options.upsert) { var newDoc = _.clone(selector); LocalCollection._modify(newDoc, mod, true); + if (! newDoc._id && options.insertedId) + newDoc._id = options.insertedId; insertedId = self.insert(newDoc); updateCount = 1; } diff --git a/packages/minimongo/modify.js b/packages/minimongo/modify.js index 0e404914dd..dc9fda5964 100644 --- a/packages/minimongo/modify.js +++ b/packages/minimongo/modify.js @@ -62,7 +62,11 @@ LocalCollection._modify = function (doc, mod, onInsert) { // Note: this used to be for (var k in doc) however, this does not // work right in Opera. Deleting from a doc while iterating over it // would sometimes cause opera to skip some keys. - if (k !== '_id') + + // onInsert: if we're constructing a document to insert (via upsert) + // and we're in replacement mode, not modify mode, DON'T take the + // _id from the query. This matches mongo's behavior. + if (k !== '_id' || onInsert) delete doc[k]; }); for (var k in new_doc) { diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index f0a5bcc8df..bf809c1b8c 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -257,6 +257,16 @@ MongoConnection.prototype._refresh = function (collectionName, selector) { } }; +var numberAffectedCallback = function (callback) { + return Meteor.bindEnvironment(function (err, numberAffected, extraInfo) { + callback && callback(err, ! err && { + numberAffected: numberAffected + }, extraInfo); + }, function (err) { + Meteor._debug("Error in Mongo write:", err.stack); + }); +}; + MongoConnection.prototype._remove = function (collection_name, selector, callback) { var self = this; @@ -330,10 +340,9 @@ MongoConnection.prototype._update = function (collection_name, selector, mod, var mongoMod = replaceTypes(mod, replaceMeteorAtomWithMongo); var isModify = isModificationMod(mongoMod); + var knownId = (isModify ? mongoSelector._id : mongoMod._id); - if (options.upsert && - (isModify ? (! mongoSelector._id) : (! mongoMod._id)) && - options.insertedId) { + if (options.upsert && (! knownId) && options.insertedId) { mongoOpts.insertedId = options.insertedId; simulateUpsertWithInsertedId(collection, mongoSelector, mongoMod, isModify, mongoOpts, function (err, result) { @@ -348,8 +357,18 @@ MongoConnection.prototype._update = function (collection_name, selector, mod, callback(err, result); }); } else { - // For non-upserts, just return the number of affected documents. - collection.update(mongoSelector, mongoMod, mongoOpts, callback); + collection.update(mongoSelector, mongoMod, mongoOpts, + numberAffectedCallback(function (err, result, extra) { + if (! err) { + if (options.upsert && (! extra.updatedExisting) && + knownId) { + result.insertedId = knownId; + } + if (result && ! options.returnObject) + result = result.numberAffected; + } + callback(err, result); + })); } } catch (e) { write.committed(); diff --git a/packages/mongo-livedata/mongo_livedata_tests.js b/packages/mongo-livedata/mongo_livedata_tests.js index 0f9f5da7d9..9a564b6570 100644 --- a/packages/mongo-livedata/mongo_livedata_tests.js +++ b/packages/mongo-livedata/mongo_livedata_tests.js @@ -943,7 +943,7 @@ if (Meteor.isServer) { _.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { _.each([true, false], function (useUpdate) { - Tinytest.addAsync("mongo-livedata - " + (useUpdate ? "update " : "") + "upsert " + (minimongo ? "minimongo" : "") + ", " + idGeneration, function (test, onComplete) { + Tinytest.addAsync("mongo-livedata - " + (useUpdate ? "update " : "") + "upsert" + (minimongo ? " minimongo" : "") + ", " + idGeneration, function (test, onComplete) { var upsert = function (query, mod, options) { if (useUpdate) return { numberAffected: @@ -960,6 +960,7 @@ _.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { actual = _.map(actual, stripId); expected = _.map(expected, stripId); } + // (technically should ignore order in comparison) test.equal(actual, expected); }; @@ -1001,7 +1002,7 @@ _.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { coll.remove({}); - // Test modification + // Test modification by upsert var result5 = upsert({name: 'David'}, {$set: {foo: 1}}); test.equal(result5.numberAffected, 1); @@ -1052,6 +1053,119 @@ _.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, {name: 'Fred', foo: 2, bar: 7, _id: fredId}]); + // test `insertedId` option + var result9 = upsert({name: 'Steve'}, + {name: 'Steve'}, + {insertedId: 'steve'}); + test.equal(result9.numberAffected, 1); + if (! useUpdate) + test.equal(result9.insertedId, 'steve'); + compareResults(coll.find().fetch(), + [{name: 'David', foo: 2, bar: 7, _id: davidId}, + {name: 'Emily', foo: 2, bar: 7, _id: emilyId}, + {name: 'Fred', foo: 2, bar: 7, _id: fredId}, + {name: 'Steve', _id: 'steve'}]); + test.isTrue(coll.findOne('steve')); + test.isFalse(coll.findOne('fred')); + + onComplete(); + }); + }); +}); + +_.each(Meteor.isServer ? [true, false] : [true], function (minimongo) { + _.each([true, false], function (useUpdate) { + Tinytest.addAsync("mongo-livedata - " + (useUpdate ? "update " : "") + "upsert by id" + (minimongo ? " minimongo" : "") + ", " + idGeneration, function (test, onComplete) { + var upsert = function (query, mod, options) { + if (useUpdate) + return { numberAffected: + coll.update(query, mod, + _.extend({ upsert: true }, options)) }; + else + return coll.upsert(query, mod, options); + }; + var stripId = function (obj) { + delete obj._id; + }; + var compareResults = function (actual, expected, stripIds) { + if (stripIds) { + actual = _.map(actual, stripId); + expected = _.map(expected, stripId); + } + // (technically should ignore order in comparison) + test.equal(actual, expected); + }; + + var run = test.runId(); + var options = collectionOptions; + if (minimongo) + options = _.extend({}, collectionOptions, { connection: null }); + var coll = new Meteor.Collection("livedata_upsert_by_id_collection_"+run, options); + + var ret; + ret = upsert({_id: 'foo'}, {$set: {x: 1}}); + test.equal(ret.numberAffected, 1); + if (! useUpdate) + test.equal(ret.insertedId, 'foo'); + compareResults(coll.find().fetch(), + [{_id: 'foo', x: 1}]); + + ret = upsert({_id: 'foo'}, {$set: {x: 2}}); + test.equal(ret.numberAffected, 1); + if (! useUpdate) + test.isFalse(ret.insertedId); + compareResults(coll.find().fetch(), + [{_id: 'foo', x: 2}]); + + ret = upsert({_id: 'bar'}, {$set: {x: 1}}); + test.equal(ret.numberAffected, 1); + if (! useUpdate) + test.equal(ret.insertedId, 'bar'); + compareResults(coll.find().fetch(), + [{_id: 'foo', x: 2}, + {_id: 'bar', x: 1}]); + + coll.remove({}); + + ret = upsert({_id: 'traz'}, {x: 1}); + test.equal(ret.numberAffected, 1); + var myId = ret.insertedId; + if (! useUpdate) { + test.isTrue(myId); + // upsert with entire document does NOT take _id from + // the query. + test.notEqual(myId, 'traz'); + } else { + myId = coll.findOne()._id; + } + compareResults(coll.find().fetch(), + [{x: 1, _id: myId}]); + + // this time, insert as _id 'traz' + ret = upsert({_id: 'traz'}, {_id: 'traz', x: 2}); + test.equal(ret.numberAffected, 1); + if (! useUpdate) + test.equal(ret.insertedId, 'traz'); + compareResults(coll.find().fetch(), + [{x: 1, _id: myId}, + {x: 2, _id: 'traz'}]); + + // now update _id 'traz' + ret = upsert({_id: 'traz'}, {x: 3}); + test.equal(ret.numberAffected, 1); + test.isFalse(ret.insertedId); + compareResults(coll.find().fetch(), + [{x: 1, _id: myId}, + {x: 3, _id: 'traz'}]); + + // now update, passing _id (which is ok as long as it's the same) + ret = upsert({_id: 'traz'}, {_id: 'traz', x: 4}); + test.equal(ret.numberAffected, 1); + test.isFalse(ret.insertedId); + compareResults(coll.find().fetch(), + [{x: 1, _id: myId}, + {x: 4, _id: 'traz'}]); + onComplete(); }); }); From 22d3a56b813fa28ba219e69da86b103412dae545 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 19:13:42 -0700 Subject: [PATCH 126/335] remove duplicate function --- packages/mongo-livedata/mongo_driver.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/mongo-livedata/mongo_driver.js b/packages/mongo-livedata/mongo_driver.js index bf809c1b8c..0e39b82b1d 100644 --- a/packages/mongo-livedata/mongo_driver.js +++ b/packages/mongo-livedata/mongo_driver.js @@ -383,13 +383,6 @@ var isModificationMod = function (mod) { return false; }; -// Assumes callback has already been wrapped with bindEnvironment. -var numberAffectedCallback = function (callback) { - return function (err, result) { - callback(err, ! err && { numberAffected: result }); - }; -}; - var NUM_OPTIMISTIC_TRIES = 3; // exposed for testing From 6329cedc51a9f48bf7e00876e1b82c09330b3536 Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Sep 2013 19:41:24 -0700 Subject: [PATCH 127/335] upsert method -- let upsert go over the network Only works in insecure mode. (Not supported by allow/deny.) --- packages/mongo-livedata/collection.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/mongo-livedata/collection.js b/packages/mongo-livedata/collection.js index 7f95b7c5df..8f8f6cdf01 100644 --- a/packages/mongo-livedata/collection.js +++ b/packages/mongo-livedata/collection.js @@ -568,6 +568,7 @@ Meteor.Collection.prototype._defineMutationMethods = function() { insert: {allow: [], deny: []}, update: {allow: [], deny: []}, remove: {allow: [], deny: []}, + upsert: {allow: [], deny: []}, // dummy arrays; can't set these! fetch: [], fetchAllFields: false }; @@ -583,7 +584,7 @@ Meteor.Collection.prototype._defineMutationMethods = function() { if (self._connection) { var m = {}; - _.each(['insert', 'update', 'remove'], function (method) { + _.each(['insert', 'update', 'remove', 'upsert'], function (method) { m[self._prefix + method] = function (/* ... */) { // All the methods do their own validation, instead of using check(). check(arguments, [Match.Any]); @@ -597,8 +598,12 @@ Meteor.Collection.prototype._defineMutationMethods = function() { return; } - // This is the server receiving a method call from the client. We - // don't allow arbitrary selectors in mutations from the client: only + // This is the server receiving a method call from the client. + + if (method === 'upsert' && ! self._isInsecure()) + throw new Meteor.Error(403, "Not permitted. Untrusted code may not upsert (not supported by allow/deny). Upsert from a method instead."); + + // We don't allow arbitrary selectors in mutations from the client: only // single-ID selectors. if (method !== 'insert') throwIfSelectorIsNotId(arguments[0], method); From 4893fe048c556a196c146b074fc2fb45b55734df Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 13 Aug 2013 21:50:45 -0700 Subject: [PATCH 128/335] Package for security-related http headers. --- docs/client/docs.js | 4 +- docs/client/packages.html | 2 + docs/client/packages/browser-policy.html | 126 +++++++++++ .../packages/starter-browser-policy.html | 25 +++ packages/browser-policy/.gitignore | 1 + packages/browser-policy/browser-policy.js | 200 ++++++++++++++++++ packages/browser-policy/package.js | 9 + packages/starter-browser-policy/.gitignore | 1 + packages/starter-browser-policy/package.js | 8 + .../starter-browser-policy.js | 13 ++ packages/webapp/webapp_server.js | 40 +++- tools/app.html.in | 4 +- 12 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 docs/client/packages/browser-policy.html create mode 100644 docs/client/packages/starter-browser-policy.html create mode 100644 packages/browser-policy/.gitignore create mode 100644 packages/browser-policy/browser-policy.js create mode 100644 packages/browser-policy/package.js create mode 100644 packages/starter-browser-policy/.gitignore create mode 100644 packages/starter-browser-policy/package.js create mode 100644 packages/starter-browser-policy/starter-browser-policy.js diff --git a/docs/client/docs.js b/docs/client/docs.js index c057ef71bd..036ed23c89 100644 --- a/docs/client/docs.js +++ b/docs/client/docs.js @@ -344,7 +344,9 @@ var toc = [ "spiderable", "stylus", "showdown", - "underscore" + "underscore", + "browser-policy", + "starter-browser-policy" ] ], "Command line", [ [ diff --git a/docs/client/packages.html b/docs/client/packages.html index 0ee701bbe8..ac344171cd 100644 --- a/docs/client/packages.html +++ b/docs/client/packages.html @@ -32,6 +32,8 @@ and removed with: {{> pkg_stylus}} {{> pkg_showdown}} {{> pkg_underscore}} +{{> pkg_browser_policy}} +{{> pkg_starter_browser_policy}} {{/better_markdown}} diff --git a/docs/client/packages/browser-policy.html b/docs/client/packages/browser-policy.html new file mode 100644 index 0000000000..b2da7d5117 --- /dev/null +++ b/docs/client/packages/browser-policy.html @@ -0,0 +1,126 @@ + diff --git a/docs/client/packages/browser-policy.html b/docs/client/packages/browser-policy.html index b2da7d5117..efc4c2c275 100644 --- a/docs/client/packages/browser-policy.html +++ b/docs/client/packages/browser-policy.html @@ -16,12 +16,13 @@ attacks. tells the browser where your app can load content from, which encourages safe practices and mitigates the damage of a cross-site-scripting attack. -For most apps, we recommend that you use the `starter-browser-policy` package to -enable reasonable policies, and then use the functions below to tighten or relax -the policies as necessary. For example, an app that only loads content from its -own origin and that doesn't use inline Javascript should use -`starter-browser-policy` and then call `BrowserPolicy.disallowInlineScripts()` -to gain additional security against cross-site scripting attacks. +For most apps, we recommend that you use the +[`starter-browser-policy`](#starterbrowserpolicy) package to enable reasonable +policies, and then use the functions below to tighten or relax the policies as +necessary. For example, an app that only loads content from its own origin and +that doesn't use inline Javascript should use `starter-browser-policy` and then +call `BrowserPolicy.disallowInlineScripts()` to gain additional security against +cross-site scripting attacks. You can use the following functions to specify which websites are allowed to frame your app: @@ -31,10 +32,11 @@ Your app will never render inside a frame or iframe. {{/dtdd}} {{#dtdd "BrowserPolicy.allowFramingByOrigin(origin)"}} -Your app will only render inside frames loaded by `origin` (such as -http://meteor.com). You can only call this function once with a single origin, -and cannot specify multiple origins that are allowed to frame your app. (This is -a limitation of the X-Frame-Options header.) +Your app will only render inside frames loaded by `origin`. You can only call +this function once with a single origin, and cannot use wildcards or specify +multiple origins that are allowed to frame your app. (This is a limitation of +the X-Frame-Options header.) Example values of `origin` include +"http://example.com" and "https://foo.example.com". {{/dtdd}} {{#dtdd "BrowserPolicy.allowFramingBySameOrigin()"}} @@ -79,17 +81,7 @@ Disallows inline CSS. Finally, you can configure a whitelist of allowed requests that various types of -content can make. Examples: - -* `BrowserPolicy.disallowObject()` causes the browser to disallow all -`` tags. -* `BrowserPolicy.allowImageOrigin("https://example.com")` -allows images to have their `src` attributes point to images served from -`https://example.com`. -* `BrowserPolicy.allowConnectOrigin("https://example.com")` allows XMLHttpRequest -and WebSocket connections to `https://example.com`. - -The following functions are defined for the content types +content can make. The following functions are defined for the content types script, object, image, media, font, and connect.
@@ -122,5 +114,16 @@ app's origin but you want to disable `` tags, you can simply call `BrowserPolicy.allowAllContentSameOrigin()` followed by `BrowserPolicy.disallowObject()`. +Other examples of using the `BrowserPolicy` API: + +* `BrowserPolicy.disallowObject()` causes the browser to disallow all +`` tags. +* `BrowserPolicy.allowImageOrigin("https://example.com")` +allows images to have their `src` attributes point to images served from +`https://example.com`. +* `BrowserPolicy.allowConnectOrigin("https://example.com")` allows XMLHttpRequest +and WebSocket connections to `https://example.com`. + + {{/better_markdown}} diff --git a/docs/client/packages/starter-browser-policy.html b/docs/client/packages/starter-browser-policy.html index bb1118fc0d..71ff88911a 100644 --- a/docs/client/packages/starter-browser-policy.html +++ b/docs/client/packages/starter-browser-policy.html @@ -2,10 +2,10 @@ {{#better_markdown}} ## `starter-browser-policy` -The `starter-browser-policy` package provides a recommended configuration for the -`browser-policy` package. When you add `starter-browser-policy` to your app, the -following policies will be enforced by browsers that support the X-Frame-Options -and Content-Security-Policy headers: +The `starter-browser-policy` package provides a recommended configuration for +the [`browser-policy`](#browserpolicy) package. When you add +`starter-browser-policy` to your app, the following policies will be enforced by +browsers that support the X-Frame-Options and Content-Security-Policy headers: * Only webpages on the same origin as your app can frame your app. * Your app can only load content (images, scripts, fonts, etc.) from its own diff --git a/packages/browser-policy/browser-policy.js b/packages/browser-policy/browser-policy.js index 3d9d8e330c..5f54c8d6d3 100644 --- a/packages/browser-policy/browser-policy.js +++ b/packages/browser-policy/browser-policy.js @@ -81,6 +81,7 @@ var removeCspSrc = function (directive, src) { }; var ensureDirective = function (directive) { + cspSrcs = cspSrcs || {}; if (! _.has(cspSrcs, directive)) cspSrcs[directive] = []; }; diff --git a/packages/webapp/package.js b/packages/webapp/package.js index 0f89553822..c451f2086a 100644 --- a/packages/webapp/package.js +++ b/packages/webapp/package.js @@ -12,6 +12,9 @@ Package.on_use(function (api) { api.use(['application-configuration'], { unordered: true }); + api.use(['browser-policy'], { + unordered: true + }); api.export(['WebApp', 'main', 'WebAppInternals'], 'server'); api.add_files('webapp_server.js', 'server'); }); From a102872a963e367962f91291a37b0fff7df1e38e Mon Sep 17 00:00:00 2001 From: Emily Stark Date: Tue, 10 Sep 2013 17:08:42 -0700 Subject: [PATCH 130/335] Rework browser-policy to make API more intuitive. - Remove starter-browser-policy and replace it with BrowserPolicy.enableContentSecurityPolicy(), which gives you the starter policy and allows you to use the other BrowserPolicy functions to configure it. This is motivated by the fact that the API isn't very intuitive without a well-defined starting policy. ex: if the package starts off without a policy, and then the user calls allowAllContentSameOrigin(), that will result in turning off inline scripts, which is probably not what they wanted. - AllContent functions do more of what you'd expect now; i.e. BrowserPolicy.disallowAllContent() actually disallows all content, instead of setting default-src to 'none', which will allow other types of content that have previously had srcs set for them. - Add some tests --- docs/client/docs.js | 1 - docs/client/packages.html | 1 - docs/client/packages/browser-policy.html | 53 +++++-- .../packages/starter-browser-policy.html | 25 --- .../browser-policy/browser-policy-test.js | 123 +++++++++++++++ packages/browser-policy/browser-policy.js | 148 +++++++++++++----- packages/browser-policy/package.js | 5 + packages/starter-browser-policy/.gitignore | 1 - packages/starter-browser-policy/package.js | 8 - .../starter-browser-policy.js | 13 -- 10 files changed, 277 insertions(+), 101 deletions(-) delete mode 100644 docs/client/packages/starter-browser-policy.html create mode 100644 packages/browser-policy/browser-policy-test.js delete mode 100644 packages/starter-browser-policy/.gitignore delete mode 100644 packages/starter-browser-policy/package.js delete mode 100644 packages/starter-browser-policy/starter-browser-policy.js diff --git a/docs/client/docs.js b/docs/client/docs.js index dc489c7cb5..3ac001e554 100644 --- a/docs/client/docs.js +++ b/docs/client/docs.js @@ -345,7 +345,6 @@ var toc = [ "spiderable", "stylus", "showdown", - "starter-browser-policy", "underscore" ] ], diff --git a/docs/client/packages.html b/docs/client/packages.html index c891bbc5cd..077e4f1cd1 100644 --- a/docs/client/packages.html +++ b/docs/client/packages.html @@ -32,7 +32,6 @@ and removed with: {{> pkg_spiderable}} {{> pkg_stylus}} {{> pkg_showdown}} -{{> pkg_starter_browser_policy}} {{> pkg_underscore}} {{/better_markdown}} diff --git a/docs/client/packages/browser-policy.html b/docs/client/packages/browser-policy.html index efc4c2c275..5b8e31354e 100644 --- a/docs/client/packages/browser-policy.html +++ b/docs/client/packages/browser-policy.html @@ -16,13 +16,26 @@ attacks. tells the browser where your app can load content from, which encourages safe practices and mitigates the damage of a cross-site-scripting attack. -For most apps, we recommend that you use the -[`starter-browser-policy`](#starterbrowserpolicy) package to enable reasonable -policies, and then use the functions below to tighten or relax the policies as -necessary. For example, an app that only loads content from its own origin and -that doesn't use inline Javascript should use `starter-browser-policy` and then -call `BrowserPolicy.disallowInlineScripts()` to gain additional security against -cross-site scripting attacks. +For most apps, we recommend that you take the following steps when using +`browser-policy`: + +* Call `BrowserPolicy.enableContentSecurityPolicy()` to enable a starter policy +for your app. With this starter policy, your app's client code will be able to +load content (images, scripts, fonts, etc.) only from its own origin, except +that XMLHttpRequests and WebSocket connections can go to any origin. Further, +your app's client code will not be able to use functions such as `eval()` that +convert strings to code. +* You can use the functions described below to customize the content +security policy. If your app does not need any inline Javascript such as inline +`