diff --git a/configs/client-default.js b/configs/client-default.js index 550221b9..9f42185b 100644 --- a/configs/client-default.js +++ b/configs/client-default.js @@ -702,6 +702,7 @@ module.exports = function(options) { premium: options.project.premium, } }, + "plugins/c9.ide.abtesting/abtesting", { packagePath: "plugins/c9.ide.welcome/welcome", staticPrefix: staticPrefix + "/plugins/c9.ide.welcome", diff --git a/configs/standalone.js b/configs/standalone.js index 8b7e468c..290e0892 100644 --- a/configs/standalone.js +++ b/configs/standalone.js @@ -158,7 +158,6 @@ module.exports = function(config, optimist) { "c9.vfs.client": true, "c9.cli.bridge": true, "c9.nodeapi": true, - "c9.ide.experiment": true, "saucelabs.preview": true, "salesforce.sync": true, "salesforce.language": true @@ -194,7 +193,6 @@ module.exports = function(config, optimist) { "./c9.vfs.server/statics", "./c9.analytics/mock_analytics", "./c9.metrics/mock_metrics", - "./c9.ide.experiment/mock_experiment", { packagePath: "./c9.vfs.server/vfs.connect.standalone", workspaceDir: baseProc, diff --git a/node_modules/c9/ratelimit.js b/node_modules/c9/ratelimit.js index 404c6dfa..2b4c4ef7 100644 --- a/node_modules/c9/ratelimit.js +++ b/node_modules/c9/ratelimit.js @@ -1,32 +1,46 @@ var error = require("http-error"); +var MAX_EXPIRE_INTERVAL = 5000; + /** * In memory rate limiter as connect middleware */ module.exports = ratelimit; function ratelimit(key, duration, max) { - var limit = {}; + var requests = Object.create(null); // in case there handles like 'constructor' + setInterval(function() { + Object.keys(requests).forEach(expireRequests); + }, Math.min(duration * 0.75, MAX_EXPIRE_INTERVAL)); + + function expireRequests(handle) { + var requestsForHandle = requests[handle]; + var totalToSplice = 0; + var expireTime = Date.now() - duration; + /* Requests are already sorted by date as they are appended, so we just loop + until we find one that shouldn't have expired and splice them from the list */ + for (totalToSplice = 0; totalToSplice < requestsForHandle.length; totalToSplice++) { + if (requestsForHandle[totalToSplice] >= expireTime) break; + } + requests[handle].splice(0, totalToSplice); + if (requests[handle].length == 0) { + delete requests[handle]; + } + + return true; + } + return function(req, res, next) { var handle = req.params[key]; - var lim = limit[handle] || (limit[handle] = []); - var now = Date.now(); - for (var i = 0; i < lim.length; i++) { - if (now - lim[i] > duration) { - lim.splice(i, 1); - i--; - } - else break; - } - if (lim.length > max) { + requests[handle] = requests[handle] || []; + if (requests[handle].length >= max) { var err = new error.TooManyRequests("Rate limit exceeded"); - err.retryIn = duration - (Date.now() - lim[0]); - next(err); - return; + err.retryIn = Math.min(duration, 5000); + return next(err); } - lim.push(Date.now()); + requests[handle].push(Date.now()); return next(); }; } diff --git a/node_modules/c9/ratelimit_test.js b/node_modules/c9/ratelimit_test.js new file mode 100644 index 00000000..fd674350 --- /dev/null +++ b/node_modules/c9/ratelimit_test.js @@ -0,0 +1,104 @@ +"use server"; + +require("c9/inline-mocha")(module); + +var ratelimit = require("./ratelimit"); +var assert = require("assert"); +var async = require("async"); + +describe("ratelimit", function() { + + it("Should limit based on key", function (done) { + var limiter = ratelimit("username", 10, 1); + limiter({params: {username: "super"}}, null, function (err) { + assert(!err, err); + limiter({params: {username: "super"}}, null, function (err) { + assert(err); + assert.equal(err.code, 429); + done(); + }); + }); + }); + + it("Should work with different keys", function (done) { + var limiter = ratelimit("username", 10, 1); + limiter({params: {username: "super"}}, null, function (err) { + assert(!err, err); + limiter({params: {username: "aloha"}}, null, function (err) { + assert(!err, err); + done(); + }); + }); + }); + + + + it("Should work again after a delay", function (done) { + var limiter = ratelimit("username", 10, 1); + limiter({params: {username: "super"}}, null, function (err) { + assert(!err, err); + setTimeout(function() { + limiter({params: {username: "super"}}, null, function (err) { + assert(!err, err); + done(); + }); + }, 25); + }); + }); + + it("Should work with many requests", function (done) { + var MAX_REQUESTS = 5; + var limiter = ratelimit("username", 10, MAX_REQUESTS); + var successfulRequests = 0; + async.times(10, function(n, next) { + limiter({params: {username: "super"}}, null, function (err) { + if (err) return next(err); + successfulRequests++; + next(); + }); + }, function (err) { + assert.equal(successfulRequests, MAX_REQUESTS); + setTimeout(function() { + limiter({params: {username: "super"}}, null, function (err) { + assert(!err, err); + done(); + }); + }, 25); + }); + }); + + it("Should expire keys at the correct times", function (done) { + var limiter = ratelimit("username", 50, 2); + async.series([ + function(next) { + limiter({params: {username: "mario"}}, null, function(err) { + assert(!err, err); + setTimeout(next, 25); + }); + }, + function (next) { + limiter({params: {username: "mario"}}, null, function(err) { + assert(!err, err); + setTimeout(next, 40); + }); + }, + function (next) { + limiter({params: {username: "mario"}}, null, function(err) { + assert(!err, err); + limiter({params: {username: "mario"}}, null, function(err) { + assert(err); + assert.equal(err.code, 429); + setTimeout(next, 20); + }); + }); + }, + function (next) { + limiter({params: {username: "mario"}}, null, function(err) { + assert(!err, err); + next(); + }); + } + ], done); + }); + +}); \ No newline at end of file diff --git a/package.json b/package.json index c6584b2e..417363a6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "c9", "description": "New Cloud9 Client", - "version": "3.1.2449", + "version": "3.1.2532", "author": "Ajax.org B.V. ", "private": true, "main": "bin/c9", @@ -71,7 +71,7 @@ "c9.ide.language.javascript.infer": "#18acb93a3a", "c9.ide.language.jsonalyzer": "#d8183d84b4", "c9.ide.language.codeintel": "#fc867feec4", - "c9.ide.collab": "#85ce41adac", + "c9.ide.collab": "#eda7de90a9", "c9.ide.local": "#10eb45842a", "c9.ide.find": "#e33fbaed2f", "c9.ide.find.infiles": "#c0a13737ef", @@ -97,7 +97,7 @@ "c9.ide.installer": "#b2e4ba0a92", "c9.ide.language.python": "#aff0772c78", "c9.ide.language.go": "#6ce1c7a7ef", - "c9.ide.mount": "#4c39359b87", + "c9.ide.mount": "#6ddfd05db3", "c9.ide.navigate": "#5d5707058c", "c9.ide.newresource": "#981a408a7b", "c9.ide.openfiles": "#2ae85a9e33", diff --git a/plugins/c9.abtesting/abtesting.js b/plugins/c9.abtesting/abtesting.js new file mode 100644 index 00000000..44fc2b00 --- /dev/null +++ b/plugins/c9.abtesting/abtesting.js @@ -0,0 +1,86 @@ +/** + * Server-side support for A/B testing experiments. + * + * Note that this plugin demands users and user IDs for most of its API, + * where this is optional in the client-side implementation. + */ +main.consumes = ["analytics"]; +main.provides = ["abtesting"]; +module.exports = main; + +function main(options, imports, register) { + var analytics = imports["analytics"]; + var outplan = require("outplan"); + + var MS_PER_DAY = 1000 * 60 * 60 * 24; + + outplan.configure({ + logFunction: function(e) { + var label = e.name + " - " + e.event; + analytics.track(label, { variation: e.params.name }); + } + }); + + function create(name, choices, options) { + return outplan.create(name, choices, options); + } + + function expose(experimentName, userId, options) { + return outplan.expose(experimentName, userId, options); + } + + function isUserCreatedAfter(experimentDate, user) { + if (!user || !user.date_add) + throw new Error("Expected: user"); + var diffDays = (experimentDate - Date.now()) / MS_PER_DAY; + if (diffDays > 20) { + // Sanity check: new Date() takes zero-based month argument, one-based day argument + throw new Error("Passed a date far in the future to isUserCreatedAfter()"); + } + return user.date_add > experimentDate; + } + + register(null, { + "abtesting": { + /** + * Create a new experiment. Alias for require("outplan").create() + * + * @param {String} name + * The name of the experiment. + * @param {String[]|Object[]} choices + * A list of variations, e.g. ["A", "B"], + * or variation objects, e.g. [{ name: "A", color: "#AAA" }, { name: "B", color: "#BBB" }] + * @param {Object} [option] + * Options for the experiment. This may also include + * arguments for the distribution operator, e.g. weight. + * @param {Function} [options.operator] + * The distribution operator, e.g. outplan.WeightedChoice. + */ + create: create, + + /** + * Get the selected variation of an experiment, and call the log function with + * an "expose" event to track its exposure. + * + * @param {String} name The experiment name. + * @param {Number} userId A unique identifier for the current user. + * @param {Object} [options] Options + * @param {Boolean} [options.log=true] Whether to log an "exposure event" + */ + expose: expose, + + /** + * Helper to determine if the current user was created after the start of an experiment. + * + * @throws {Error} when a date in the future (~20 days from now) is passed. + * This error is thrown as a sanity check to make sure `new Date()` + * is called with a zero-based month argument (and a one-based day). + * + * @param {Date} experimentDate + * @param {Object} user A user object + * @param {Number} user.date_add + */ + isUserCreatedAfter: isUserCreatedAfter, + } + }); +} diff --git a/plugins/c9.error/views/error-401.html.ejs b/plugins/c9.error/views/error-401.html.ejs index 41c00594..c2df5328 100644 --- a/plugins/c9.error/views/error-401.html.ejs +++ b/plugins/c9.error/views/error-401.html.ejs @@ -13,7 +13,7 @@

Status Page | - Support | + Support | Dashboard | Home diff --git a/plugins/c9.error/views/error-404.html.ejs b/plugins/c9.error/views/error-404.html.ejs index 71d0152d..50572e49 100644 --- a/plugins/c9.error/views/error-404.html.ejs +++ b/plugins/c9.error/views/error-404.html.ejs @@ -13,7 +13,7 @@

Status Page | - Support | + Support | Dashboard | Home diff --git a/plugins/c9.error/views/error-500.html.ejs b/plugins/c9.error/views/error-500.html.ejs index f833a5e0..f54b5848 100644 --- a/plugins/c9.error/views/error-500.html.ejs +++ b/plugins/c9.error/views/error-500.html.ejs @@ -30,7 +30,7 @@ <% } %> Status Page | - Support | + Support | Dashboard | Home diff --git a/plugins/c9.error/views/error-503.html.ejs b/plugins/c9.error/views/error-503.html.ejs index eb7394ba..6323c2e2 100644 --- a/plugins/c9.error/views/error-503.html.ejs +++ b/plugins/c9.error/views/error-503.html.ejs @@ -29,7 +29,7 @@ <% } %> Status Page | - Support | + Support | Dashboard | Home diff --git a/plugins/c9.error/views/error.html.ejs b/plugins/c9.error/views/error.html.ejs index 54bd1ec9..b686bbec 100644 --- a/plugins/c9.error/views/error.html.ejs +++ b/plugins/c9.error/views/error.html.ejs @@ -27,7 +27,7 @@ <% } %> Status Page | - Support | + Support | Dashboard | Home diff --git a/plugins/c9.ide.abtesting/abtesting.js b/plugins/c9.ide.abtesting/abtesting.js new file mode 100644 index 00000000..1a7160db --- /dev/null +++ b/plugins/c9.ide.abtesting/abtesting.js @@ -0,0 +1,105 @@ +/** + * Client-side support for A/B testing experiments. + */ +define(function(require, exports, module) { + "use strict"; + + main.consumes = ["Plugin", "info", "c9.analytics"]; + main.provides = ["abtesting"]; + return main; + + function main(options, imports, register) { + var Plugin = imports.Plugin; + var plugin = new Plugin("Ajax.org", main.consumes); + var info = imports.info; + var analytics = imports["c9.analytics"]; + var outplan = require("outplan"); + + var MS_PER_DAY = 1000 * 60 * 60 * 24; + + var userId; + + plugin.on("load", function() { + userId = info.getUser().id; + outplan.configure({ + logFunction: function(e) { + var label = e.name + " - " + e.event; + analytics.track(label, { variation: e.params.name }); + } + }); + }); + + plugin.on("unload", function() { + userId = undefined; + }); + + function create(name, choices, options) { + var experiment = outplan.create(name, choices, options); + experiment.expose = expose.bind(null, name); + return experiment; + } + + function expose(experimentName, overrideUserId, options) { + if (overrideUserId && typeof overrideUserId === "object") + return expose(experimentName, null, options); + + return outplan.expose(experimentName, overrideUserId == null ? userId : overrideUserId, options); + } + + function isUserCreatedAfter(experimentDate) { + var diffDays = (experimentDate - Date.now()) / MS_PER_DAY; + if (diffDays > 20) { + // Sanity check: new Date() takes zero-based month argument, one-based day argument + throw new Error("Passed a date far in the future to isUserCreatedAfter()"); + } + return info.getUser().date_add > experimentDate; + } + + /** + * Support for A/B testing experiments. + */ + plugin.freezePublicAPI({ + /** + * Create a new experiment. Alias for require("outplan").create() + * + * @param {String} name + * The name of the experiment. + * @param {String[]|Object[]} choices + * A list of variations, e.g. ["A", "B"], + * or variation objects, e.g. [{ name: "A", color: "#AAA" }, { name: "B", color: "#BBB" }] + * @param {Object} [option] + * Options for the experiment. This may also include + * arguments for the distribution operator, e.g. weight. + * @param {Function} [options.operator] + * The distribution operator, e.g. outplan.WeightedChoice. + */ + create: create, + + /** + * Get the selected variation of an experiment, and call the log function with + * an "expose" event to track its exposure. + * + * @param {String} name The experiment name. + * @param {Number} [userId] A unique identifier for the current user. + * @param {Object} [options] Options + * @param {Boolean} [options.log=true] Whether to log an "exposure event" + */ + expose: expose, + + /** + * Helper to determine if the current user was created after the start of an experiment. + * + * @throws {Error} when a date in the future (~20 days from now) is passed. + * This error is thrown as a sanity check to make sure `new Date()` + * is called with a zero-based month argument (and a one-based day). + * + * @param {Date} experimentDate + */ + isUserCreatedAfter: isUserCreatedAfter, + }); + + register(null, { + "abtesting": plugin + }); + } +}); diff --git a/plugins/c9.ide.experiment/mock_experiment.js b/plugins/c9.ide.experiment/mock_experiment.js deleted file mode 100644 index e3b80734..00000000 --- a/plugins/c9.ide.experiment/mock_experiment.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Dummy implementation of experiments. - */ -"use strict"; - -plugin.consumes = []; -plugin.provides = ["experiment"]; - -module.exports = plugin; - -function plugin(options, imports, register) { - - register(null, { - "experiment": { - configure: function() {}, - onStart: function() { - var chain = { - variation: function() { - return chain; - } - }; - return chain; - } - } - }); -} \ No newline at end of file diff --git a/plugins/c9.ide.server/plugins.js b/plugins/c9.ide.server/plugins.js index 4ba4113c..e92571bd 100644 --- a/plugins/c9.ide.server/plugins.js +++ b/plugins/c9.ide.server/plugins.js @@ -41,6 +41,7 @@ define(function(require, exports, module) { ui: "lib/ui", c9: "lib/c9", frontdoor: "lib/frontdoor", + outplan: "lib/outplan/dist/outplan", }; if (whitelist === "*") { @@ -70,6 +71,7 @@ define(function(require, exports, module) { "ui", "emmet", "frontdoor", + "outplan", "mocha", // TESTING "chai", // TESTING ].forEach(function(name) { diff --git a/plugins/c9.preview/views/progress.html.ejs b/plugins/c9.preview/views/progress.html.ejs index 0e0396e9..e6eff21a 100644 --- a/plugins/c9.preview/views/progress.html.ejs +++ b/plugins/c9.preview/views/progress.html.ejs @@ -12,7 +12,7 @@

Status Page | - Support | + Support | Dashboard | Home