From 6b36b058535ccf2009dcaa5e1fe0babfdd55cc97 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sat, 18 May 2013 17:57:46 +0200 Subject: [PATCH 01/13] API for retrieving a bin by ID and revision --- lib/handlers/bin.js | 82 +++++++++++++++++++++++++-------------------- lib/routes.js | 4 ++- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/lib/handlers/bin.js b/lib/handlers/bin.js index 2f02b467..2ee891af 100644 --- a/lib/handlers/bin.js +++ b/lib/handlers/bin.js @@ -71,6 +71,9 @@ module.exports = Observable.extend({ getBin: function (req, res, next) { this.render(req, res, req.bin); }, + apiGetBin: function (req, res, next) { + res.json(req.bin); + }, live: function (req, res, next) { req.live = true; next(); @@ -376,53 +379,60 @@ module.exports = Observable.extend({ }, notFound: function (req, res, next) { var files = this.defaultFiles(), - _this = this; + _this = this, + isApi = req.path.indexOf('/api') == 0; files[0] = 'not_found.html'; files[2] = 'not_found.js'; - this.loadFiles(files, function (err, results) { - if (err) { - return next(err); - } - - results.url = req.param('bin'); - - // Need to get the most recent revision from the database. - _this.models.bin.latest({id: results.url}, function (err, bin) { + if (isApi) { + res.status(404); + res.contentType('js'); + res.send({ error: 'Could not find bin with ID "' + req.param('bin') + '"'}); + } else { + this.loadFiles(files, function (err, results) { if (err) { return next(err); } - // We have a missing bin, we check the latest returned bin, if this - // is active then we simply render it assuming the user mistyped the - // url. - if (bin && bin.active) { - results = bin; - } else { - // If we have a bin then take the latest revision plus one. - results.revision = bin && (bin.revision + 1); - } + results.url = req.param('bin'); - if (req.url.indexOf('edit') > -1) { - _this.render(req, res, results); - } else { - var options = {edit: true, silent: true, csrf: req.session._csrf}; - _this.formatPreview(results, options, function (err, formatted) { - if (err) { - next(err); - } + // Need to get the most recent revision from the database. + _this.models.bin.latest({id: results.url}, function (err, bin) { + if (err) { + return next(err); + } - if (formatted) { - res.send(formatted); - } else { - res.contentType('js'); - res.send(req.bin.javascript); - } - }); - } + // We have a missing bin, we check the latest returned bin, if this + // is active then we simply render it assuming the user mistyped the + // url. + if (bin && bin.active) { + results = bin; + } else { + // If we have a bin then take the latest revision plus one. + results.revision = bin && (bin.revision + 1); + } + + if (req.url.indexOf('edit') > -1) { + _this.render(req, res, results); + } else { + var options = {edit: true, silent: true, csrf: req.session._csrf}; + _this.formatPreview(results, options, function (err, formatted) { + if (err) { + next(err); + } + + if (formatted) { + res.send(formatted); + } else { + res.contentType('js'); + res.send(req.bin.javascript); + } + }); + } + }); }); - }); + } }, loadBin: function (req, res, next) { var rev = parseInt(req.params.rev, 10) || 1, diff --git a/lib/routes.js b/lib/routes.js index ee73b5cf..c2187c9e 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -87,7 +87,6 @@ module.exports = function (app) { // Save app.post('/save', binHandler.createBin); - // Use this handler to check for a user creating/claiming their own bin url. // We use :url here to prevent loadBin() being called and returning a not // found error. @@ -122,6 +121,9 @@ module.exports = function (app) { app.get('/:bin/:quiet(quiet)?', spike.getStream, binHandler.getBinPreview); app.get('/:bin/:rev?/:quiet(quiet)?', spike.getStream, binHandler.getBinPreview); + // API methods + app.get('/api/:bin/:rev?', binHandler.apiGetBin); + // Catch all app.use(errorHandler.notFound); From d7ab06a11a52aa7ac8ae2bc7d71dc41948d9047e Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sat, 18 May 2013 18:29:13 +0200 Subject: [PATCH 02/13] Route bug fix for API requests without revision --- lib/routes.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/routes.js b/lib/routes.js index c2187c9e..06f4d200 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -55,6 +55,9 @@ module.exports = function (app) { app.get('/gist/*', binHandler.getDefault); app.post('/', binHandler.getFromPost); + // API methods + app.get('/api/:bin/:rev?', binHandler.apiGetBin); + // Login/Create account. app.post('/sethome', sessionHandler.routeSetHome); app.post('/logout', sessionHandler.logoutUser); @@ -121,9 +124,6 @@ module.exports = function (app) { app.get('/:bin/:quiet(quiet)?', spike.getStream, binHandler.getBinPreview); app.get('/:bin/:rev?/:quiet(quiet)?', spike.getStream, binHandler.getBinPreview); - // API methods - app.get('/api/:bin/:rev?', binHandler.apiGetBin); - // Catch all app.use(errorHandler.notFound); From 3457710bbd7bc54ebc4d214f8a2e228573b953f5 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sun, 19 May 2013 11:49:03 +0200 Subject: [PATCH 03/13] Create Bin via API route --- lib/app.js | 2 +- lib/handlers/bin.js | 21 +++++++++++++++++++++ lib/routes.js | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/app.js b/lib/app.js index a2443161..46d68d6b 100644 --- a/lib/app.js +++ b/lib/app.js @@ -139,7 +139,7 @@ app.connect = function (callback) { app.use(express.cookieSession({key: 'jsbin', cookie: {maxAge: 365 * 24 * 60 * 60 * 1000}})); app.use(express.urlencoded()); app.use(express.json()); - app.use(middleware.csrf({ ignore: ['/'] })); + app.use(middleware.csrf({ ignore: ['/','/api/save'] })); app.use(middleware.subdomain(app)); app.use(middleware.noslashes()); app.use(middleware.ajax()); diff --git a/lib/handlers/bin.js b/lib/handlers/bin.js index 2ee891af..1ecfccba 100644 --- a/lib/handlers/bin.js +++ b/lib/handlers/bin.js @@ -72,6 +72,7 @@ module.exports = Observable.extend({ this.render(req, res, req.bin); }, apiGetBin: function (req, res, next) { + res.contentType('js'); res.json(req.bin); }, live: function (req, res, next) { @@ -214,6 +215,26 @@ module.exports = Observable.extend({ }); }); }, + apiCreateBin: function (req, res, next) { + var params = utils.extract(req.body, 'html', 'css', 'javascript', 'settings'), + _this = this; + + this.validateBin(params, function (err) { + if (err) { + return next(err); + } + + params.settings = params.settings || '{ processors: {} }'; // set default processors + _this.models.bin.create(params, function (err, result) { + if (err) { + return next(err); + } + + res.contentType('js'); + res.json(result); + }); + }); + }, claimBin: function (req, res, next) { // Handler for ambiguous endpoint that can be used to create a new bin with // a provided bin url, clone the bin and create a new endpoint or update an diff --git a/lib/routes.js b/lib/routes.js index 06f4d200..df78cb22 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -57,6 +57,7 @@ module.exports = function (app) { // API methods app.get('/api/:bin/:rev?', binHandler.apiGetBin); + app.post('/api/save', binHandler.apiCreateBin); // Login/Create account. app.post('/sethome', sessionHandler.routeSetHome); From 364dec1b25f518ba1ea9b72d5c4757bc7c7c67de Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sun, 19 May 2013 14:16:48 +0200 Subject: [PATCH 04/13] Modify middleware to bypass CSRF for exact or regex matches --- lib/app.js | 2 +- lib/middleware.js | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/app.js b/lib/app.js index 46d68d6b..00fd5c07 100644 --- a/lib/app.js +++ b/lib/app.js @@ -139,7 +139,7 @@ app.connect = function (callback) { app.use(express.cookieSession({key: 'jsbin', cookie: {maxAge: 365 * 24 * 60 * 60 * 1000}})); app.use(express.urlencoded()); app.use(express.json()); - app.use(middleware.csrf({ ignore: ['/','/api/save'] })); + app.use(middleware.csrf({ ignore: ['/', /^\/api\//] })); app.use(middleware.subdomain(app)); app.use(middleware.noslashes()); app.use(middleware.ajax()); diff --git a/lib/middleware.js b/lib/middleware.js index d91cd3b6..74dd1528 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -96,15 +96,33 @@ module.exports = { always = {OPTIONS: 1, GET: 1, HEAD: 1}; return function (req, res, next) { - if (always[req.method] || ignore.indexOf(req.url) === -1 && !req.cors) { + if (always[req.method]) { return csrf(req, res, next); } else { - next(); + var skipCSRF = false; + ignore.forEach(function(matcher) { + if (typeof matcher === 'string') { + if (matcher == req.url) { + skipCSRF = true; + } + } else { + // regular expression matcher + if (req.url.match(matcher)) { + skipCSRF = true; + } + } + }); + + if (skipCSRF) { + next(); + } else { + return csrf(req, res, next); + } } }; }, // Checks for a subdomain in the current url, if found it sets the - // req.subdomain property. This supports existing behaviour that allows + // req.subdomain property. This supports existing behaviour that allows // subdomains to load custom config files. subdomain: function (app) { return function (req, res, next) { From 7c8d63aa7889d74f9d1ec1d5d635a228a2a22ef8 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sun, 19 May 2013 14:35:17 +0200 Subject: [PATCH 05/13] Send JSON response for API errors --- config.default.json | 1 + lib/handlers/error.js | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config.default.json b/config.default.json index f44d0e75..ec66543c 100644 --- a/config.default.json +++ b/config.default.json @@ -63,6 +63,7 @@ "activity", "all", "announcements", + "api", "api_rules", "api_terms", "apirules", diff --git a/lib/handlers/error.js b/lib/handlers/error.js index ec1271fb..1573f978 100644 --- a/lib/handlers/error.js +++ b/lib/handlers/error.js @@ -38,7 +38,7 @@ module.exports = Observable.extend({ // Fall through handler for when no routes match. notFound: function (req, res, next) { var error = new errors.NotFound('Page Does Not Exist'); - if (req.accepts('html')) { + if (req.accepts('html') && (req.url.indexOf('/api/') !== 0)) { this.renderErrorPage(error, req, res); } else { this.renderError(error, req, res); @@ -61,10 +61,10 @@ module.exports = Observable.extend({ renderError: function (err, req, res) { res.status(err.status); - if (req.accepts(['html'])) { + if (req.accepts(['html']) && (req.url.indexOf('/api/') !== 0)) { res.contentType('html'); res.send(err.toHTMLString()); - } else if (req.accepts(['json'])) { + } else if (req.accepts(['json']) || (req.indexOf('/api/') === 0)) { res.json(err); } else { res.contentType('txt'); From 666ab20a2e130ae528745edc4aa2f4ac4f060874 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sun, 19 May 2013 15:57:19 +0200 Subject: [PATCH 06/13] Remove annoying console output from SQLite driver --- lib/db/sqlite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/db/sqlite.js b/lib/db/sqlite.js index a4c21d4b..9996fe26 100644 --- a/lib/db/sqlite.js +++ b/lib/db/sqlite.js @@ -406,7 +406,7 @@ module.exports = utils.inherit(Object, { // this is likely because the settings were screwed in a beta build bin.settings = {}; } - console.log(bin); + // console.log(bin); return bin; }, From 24e397b1fb2997dd22d18bb3e15ff0e054a97b65 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sun, 19 May 2013 16:27:34 +0200 Subject: [PATCH 07/13] Create revision for bin API end point --- lib/handlers/bin.js | 51 ++++++++++++++++++++++++++++++++++++++++----- lib/routes.js | 1 + 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/lib/handlers/bin.js b/lib/handlers/bin.js index 1ecfccba..790a5af5 100644 --- a/lib/handlers/bin.js +++ b/lib/handlers/bin.js @@ -72,7 +72,6 @@ module.exports = Observable.extend({ this.render(req, res, req.bin); }, apiGetBin: function (req, res, next) { - res.contentType('js'); res.json(req.bin); }, live: function (req, res, next) { @@ -230,7 +229,6 @@ module.exports = Observable.extend({ return next(err); } - res.contentType('js'); res.json(result); }); }); @@ -259,6 +257,47 @@ module.exports = Observable.extend({ this.createRevision(req, res, next); } }, + apiCreateRevision: function (req, res, next) { + var that = this, + params = utils.extract(req.body, 'html', 'css', 'javascript', 'settings'); + + params.url = req.params.bin; + params.revision = parseInt(req.params.rev, 10) || req.bin.revision; + params.settings = params.settings || '{ processors: {} }'; // set default processors + params.summary = utils.titleForBin(params); + + this.validateBin(params, function (err) { + if (err) { + return next(err); + } + + var username = req.session.user ? req.session.user.name : undefined; + + that.models.user.isOwnerOf(username, params, function (err, result) { + var method = 'create'; + + if (result.isowner || result.found === false) { // if anonymous or user is owner + params.revision += 1; // bump the revision from the *latest* + that.models.bin.createRevision(params, function (err, result) { + var query = {id: req.params.bin, revision: result.revision}; + if (err) { + return next(err); + } + + that.models.bin.load(query, function (err, result) { + if (err) { + return next(err); + } + res.json(result); + }); + }); + } else { + res.status(403); // forbidden + res.json({ error: 'You are not the owner of this bin so you cannot create a revision' }); + } + }); + }); + }, createRevision: function (req, res, next) { var panel = req.param('panel'), params = {}, @@ -401,15 +440,17 @@ module.exports = Observable.extend({ notFound: function (req, res, next) { var files = this.defaultFiles(), _this = this, - isApi = req.path.indexOf('/api') == 0; + isApi = req.path.indexOf('/api') == 0, + errorMessage; files[0] = 'not_found.html'; files[2] = 'not_found.js'; if (isApi) { res.status(404); - res.contentType('js'); - res.send({ error: 'Could not find bin with ID "' + req.param('bin') + '"'}); + errorMessage = 'Could not find bin with ID "' + req.param('bin') + '"'; + if (req.param('rev')) { errorMessage += ' and revision ' + req.param('rev'); } + res.json({ error: errorMessage }); } else { this.loadFiles(files, function (err, results) { if (err) { diff --git a/lib/routes.js b/lib/routes.js index df78cb22..c1d5bca3 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -58,6 +58,7 @@ module.exports = function (app) { // API methods app.get('/api/:bin/:rev?', binHandler.apiGetBin); app.post('/api/save', binHandler.apiCreateBin); + app.post('/api/:bin/save', binHandler.apiCreateRevision); // Login/Create account. app.post('/sethome', sessionHandler.routeSetHome); From 52fa78aa317f9a9f4d3fb60219e9b8798486515e Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sun, 19 May 2013 16:36:03 +0200 Subject: [PATCH 08/13] API middleware adding req.isApi --- lib/app.js | 1 + lib/handlers/bin.js | 3 +-- lib/handlers/error.js | 4 ++-- lib/middleware.js | 10 ++++++++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/app.js b/lib/app.js index 00fd5c07..7f0403ad 100644 --- a/lib/app.js +++ b/lib/app.js @@ -140,6 +140,7 @@ app.connect = function (callback) { app.use(express.urlencoded()); app.use(express.json()); app.use(middleware.csrf({ ignore: ['/', /^\/api\//] })); + app.use(middleware.apiDetection()); app.use(middleware.subdomain(app)); app.use(middleware.noslashes()); app.use(middleware.ajax()); diff --git a/lib/handlers/bin.js b/lib/handlers/bin.js index 790a5af5..4dd4e3b0 100644 --- a/lib/handlers/bin.js +++ b/lib/handlers/bin.js @@ -440,13 +440,12 @@ module.exports = Observable.extend({ notFound: function (req, res, next) { var files = this.defaultFiles(), _this = this, - isApi = req.path.indexOf('/api') == 0, errorMessage; files[0] = 'not_found.html'; files[2] = 'not_found.js'; - if (isApi) { + if (req.isApi) { res.status(404); errorMessage = 'Could not find bin with ID "' + req.param('bin') + '"'; if (req.param('rev')) { errorMessage += ' and revision ' + req.param('rev'); } diff --git a/lib/handlers/error.js b/lib/handlers/error.js index 1573f978..0f5ed92c 100644 --- a/lib/handlers/error.js +++ b/lib/handlers/error.js @@ -61,10 +61,10 @@ module.exports = Observable.extend({ renderError: function (err, req, res) { res.status(err.status); - if (req.accepts(['html']) && (req.url.indexOf('/api/') !== 0)) { + if (req.accepts(['html']) && !req.isApi) { res.contentType('html'); res.send(err.toHTMLString()); - } else if (req.accepts(['json']) || (req.indexOf('/api/') === 0)) { + } else if (req.accepts(['json']) || req.isApi) { res.json(err); } else { res.contentType('txt'); diff --git a/lib/middleware.js b/lib/middleware.js index 74dd1528..3041ba8d 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -177,5 +177,15 @@ module.exports = { } next(); }; + }, + + // detect if this is an API request and add flag isApi to the request object + apiDetection: function(options) { + return function (req, res, next) { + if (req.url.indexOf('/api') === 0) { + req.isApi = true; + } + next(); + } } }; From a234941cf339216ee20d4ccc6f4b2db7eb74c337 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Mon, 20 May 2013 07:18:57 +0200 Subject: [PATCH 09/13] API can require an API key for a user (disallow anonymous) --- build/full-db-v3.mysql.sql | 4 +- build/full-db-v3.sqlite.sql | 2 + .../ownership-api-key-may-2013.mysql.sql | 3 ++ .../ownership-api-key-may-2013.sqlite.sql | 2 + config.default.json | 3 ++ lib/app.js | 2 +- lib/db/file.js | 3 ++ lib/db/mysql.js | 8 ++++ lib/db/sql_templates.json | 1 + lib/db/sqlite.js | 14 +++++++ lib/handlers/bin.js | 1 + lib/middleware.js | 42 +++++++++++++++++-- lib/models/user.js | 3 ++ lib/store.js | 1 + 14 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 build/upgrade/ownership-api-key-may-2013.mysql.sql create mode 100644 build/upgrade/ownership-api-key-may-2013.sqlite.sql diff --git a/build/full-db-v3.mysql.sql b/build/full-db-v3.mysql.sql index 6913a46e..3fca1dae 100644 --- a/build/full-db-v3.mysql.sql +++ b/build/full-db-v3.mysql.sql @@ -67,11 +67,13 @@ CREATE TABLE `ownership` ( `name` char(50) NOT NULL, `key` char(255) NOT NULL, `email` varchar(255) NOT NULL DEFAULT '', + `api_key` VARCHAR(255) NULL, `last_login` datetime NOT NULL, `created` datetime NOT NULL, `updated` datetime NOT NULL, PRIMARY KEY (`name`), - KEY `name_key` (`name`,`key`) + KEY `name_key` (`name`,`key`), + KEY `ownership_api_key` (`api_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/build/full-db-v3.sqlite.sql b/build/full-db-v3.sqlite.sql index 4a85eddf..49af4eb2 100644 --- a/build/full-db-v3.sqlite.sql +++ b/build/full-db-v3.sqlite.sql @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `ownership` ( `name` VARCHAR(50) PRIMARY KEY NOT NULL, `key` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL DEFAULT '', + `api_key` VARCHAR(255) NULL, `last_login` DATETIME NOT NULL, `created` DATETIME NOT NULL, `updated` DATETIME NOT NULL @@ -52,6 +53,7 @@ CREATE INDEX IF NOT EXISTS "sandbox_streaming_key" ON "sandbox" (`streaming_key` CREATE INDEX IF NOT EXISTS "sandbox_spam" ON "sandbox" (`created`,`last_viewed`); CREATE INDEX IF NOT EXISTS "sandbox_revision" ON "sandbox" (`url`,`revision`); CREATE INDEX IF NOT EXISTS "ownership_name_key" ON "ownership" (`name`,`key`); +CREATE INDEX IF NOT EXISTS "ownership_api_key" ON "ownership" (`api_key`); CREATE INDEX IF NOT EXISTS "owners_name_url" ON "owners" (`name`,`url`,`revision`); CREATE INDEX IF NOT EXISTS "index_owners_last_updated" ON "owners" (`name`, `last_updated`); CREATE INDEX IF NOT EXISTS "index_expires" ON "forgot_tokens" (`expires`); diff --git a/build/upgrade/ownership-api-key-may-2013.mysql.sql b/build/upgrade/ownership-api-key-may-2013.mysql.sql new file mode 100644 index 00000000..44434fb5 --- /dev/null +++ b/build/upgrade/ownership-api-key-may-2013.mysql.sql @@ -0,0 +1,3 @@ +ALTER TABLE ownership + ADD COLUMN api_key VARCHAR(255) NULL, + KEY `ownership_api_key` (`expires`); \ No newline at end of file diff --git a/build/upgrade/ownership-api-key-may-2013.sqlite.sql b/build/upgrade/ownership-api-key-may-2013.sqlite.sql new file mode 100644 index 00000000..134a3687 --- /dev/null +++ b/build/upgrade/ownership-api-key-may-2013.sqlite.sql @@ -0,0 +1,2 @@ +ALTER TABLE `ownership` ADD COLUMN `api_key` VARCHAR(255) NULL; +CREATE INDEX IF NOT EXISTS "ownership_api_key" ON "ownership" (`api_key`); \ No newline at end of file diff --git a/config.default.json b/config.default.json index ec66543c..df542b55 100644 --- a/config.default.json +++ b/config.default.json @@ -47,6 +47,9 @@ "errors": [], "report": [] }, + "api": { + "allowAnonymous": true + }, "blacklist": { "html": ["processform.cgi", "habbo.com"], "css": [], diff --git a/lib/app.js b/lib/app.js index 7f0403ad..d5d3b28f 100644 --- a/lib/app.js +++ b/lib/app.js @@ -140,7 +140,7 @@ app.connect = function (callback) { app.use(express.urlencoded()); app.use(express.json()); app.use(middleware.csrf({ ignore: ['/', /^\/api\//] })); - app.use(middleware.apiDetection()); + app.use(middleware.api({ app: app })); app.use(middleware.subdomain(app)); app.use(middleware.noslashes()); app.use(middleware.ajax()); diff --git a/lib/db/file.js b/lib/db/file.js index 9f5f5ddb..42f5d252 100644 --- a/lib/db/file.js +++ b/lib/db/file.js @@ -124,6 +124,9 @@ module.exports = utils.inherit(Object, { getUser: function (id, cb) { cb(null, null); }, + getUserByApiKey: function (email, cb) { + cb(null, null); + }, getUserByEmail: function (email, cb) { cb(null, null); }, diff --git a/lib/db/mysql.js b/lib/db/mysql.js index 823d396d..beb80336 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -200,6 +200,14 @@ module.exports = utils.inherit(Object, { } }); }, + getUserByApiKey: function (apiKey, fn) { + this.connection.query(templates.getUserByApiKey, [apiKey], function (err, results) { + if (err) { + return fn(err); + } + fn(null, results[0]); + }); + }, getUserByEmail: function (email, fn) { this.connection.query(templates.getUserByEmail, [email], function (err, results) { if (err) { diff --git a/lib/db/sql_templates.json b/lib/db/sql_templates.json index 80bfe5da..31f35dc5 100644 --- a/lib/db/sql_templates.json +++ b/lib/db/sql_templates.json @@ -5,6 +5,7 @@ "setBinPanel": "UPDATE `sandbox` SET `:panel`=?, `settings`=?, `created`=? WHERE `url`=? AND `revision`=? AND `streaming_key`=? AND `streaming_key`!='' AND `active`='y'", "binExists": "SELECT id FROM `sandbox` WHERE `url`=? LIMIT 1", "getUser": "SELECT * FROM `ownership` WHERE `name`=? LIMIT 1", + "getUserByApiKey": "SELECT * FROM `ownership` WHERE `api_key`=? LIMIT 1", "getByEmail": "SELECT * FROM `ownership` WHERE `email`=? LIMIT 1", "setUser": "INSERT INTO `ownership` (`name`, `key`, `email`, `last_login`, `created`, `updated`) VALUES (?, ?, ?, ?, ?, ?)", "touchLogin": "UPDATE `ownership` SET `last_login`=? WHERE `name`=?", diff --git a/lib/db/sqlite.js b/lib/db/sqlite.js index 9996fe26..942f183e 100644 --- a/lib/db/sqlite.js +++ b/lib/db/sqlite.js @@ -213,6 +213,20 @@ module.exports = utils.inherit(Object, { } }); }, + getUserByApiKey: function (apiKey, fn) { + var _this = this; + + this.connection.get(templates.getUserByApiKey, [apiKey], function (err, result) { + if (err) { + return fn(err); + } + + if (result) { + result = _this.convertUserDates(result); + } + fn(null, result); + }); + }, getUserByEmail: function (email, fn) { var _this = this; diff --git a/lib/handlers/bin.js b/lib/handlers/bin.js index 4dd4e3b0..f1486849 100644 --- a/lib/handlers/bin.js +++ b/lib/handlers/bin.js @@ -5,6 +5,7 @@ var async = require('asyncjs'), errors = require('../errors'), custom = require('../custom'), blacklist = require('../blacklist'), + apiConfig = require('../config').api || {}; scripts = require('../../scripts.json'), processors = require('../processors'), Observable = utils.Observable; diff --git a/lib/middleware.js b/lib/middleware.js index 3041ba8d..999f326f 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -2,7 +2,9 @@ var utils = require('./utils'), helpers = require('./helpers'), custom = require('./custom'), errors = require('./errors'), - connect = require('express/node_modules/connect'); + connect = require('express/node_modules/connect'), + models = require('./models'), + config = require('./config'); // Custom middleware used by the application. module.exports = { @@ -180,12 +182,44 @@ module.exports = { }, // detect if this is an API request and add flag isApi to the request object - apiDetection: function(options) { + api: function(options) { return function (req, res, next) { + var apiKey, + userModel = models.createModels(options.app.store).user; + if (req.url.indexOf('/api') === 0) { req.isApi = true; + if (req.query.api_key) { + apiKey = req.query.api_key; + } else if (req.headers.authorization) { + apiKey = req.headers.authorization.replace(/token\s/,''); + } + + if (config.api.allowAnonymous) { + next(); + } else { + if (!apiKey) { + res.status(403); // forbidden + res.json({ error: 'You need to provide a valid API key when using this API' }); + } else { + userModel.loadByApiKey(apiKey, function (err, user) { + if (err) { + return next(err); + } + + if (user) { + req.session.user = user; + next(); + } else { + res.status(403); // forbidden + res.json({ error: 'The API key you provided is not valid' }); + } + }); + } + } + } else { + next(); } - next(); - } + }; } }; diff --git a/lib/models/user.js b/lib/models/user.js index a088b04f..5ab0a9d7 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -12,6 +12,9 @@ module.exports = Observable.extend({ load: function (id, fn) { this.store.getUser(id, fn); }, + loadByApiKey: function (apiKey, fn) { + this.store.getUserByApiKey(apiKey, fn); + }, loadByEmail: function (email, fn) { this.store.getUserByEmail(email, fn); }, diff --git a/lib/store.js b/lib/store.js index 532a5cbc..2043a7d0 100644 --- a/lib/store.js +++ b/lib/store.js @@ -27,6 +27,7 @@ var methods = [ 'archiveBin', 'getUser', 'getUserByEmail', + 'getUserByApiKey', 'setUser', 'touchLogin', 'touchOwners', From fe0c142de7a258c6221e0f69cb5e1f0d9ee1c515 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Mon, 20 May 2013 07:24:54 +0200 Subject: [PATCH 10/13] API should return latest bin revision if not specified --- lib/handlers/bin.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/handlers/bin.js b/lib/handlers/bin.js index f1486849..9301de6a 100644 --- a/lib/handlers/bin.js +++ b/lib/handlers/bin.js @@ -73,7 +73,17 @@ module.exports = Observable.extend({ this.render(req, res, req.bin); }, apiGetBin: function (req, res, next) { - res.json(req.bin); + var params = { id: req.bin.url }; + if (req.param('rev')) { + res.json(req.bin); + } else { + this.models.bin.latest(params, function(err, result) { + if (err) { + return next(err); + } + res.json(result); + }); + } }, live: function (req, res, next) { req.live = true; From 44d030e88abc3a837eebc06227f2d5f05ecb551c Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Mon, 20 May 2013 07:33:26 +0200 Subject: [PATCH 11/13] Flag to enforce API requests over SSL --- config.default.json | 3 ++- lib/middleware.js | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/config.default.json b/config.default.json index df542b55..ae476cd3 100644 --- a/config.default.json +++ b/config.default.json @@ -48,7 +48,8 @@ "report": [] }, "api": { - "allowAnonymous": true + "allowAnonymous": true, + "requireSSL": false }, "blacklist": { "html": ["processform.cgi", "habbo.com"], diff --git a/lib/middleware.js b/lib/middleware.js index 999f326f..95f25c27 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -189,6 +189,15 @@ module.exports = { if (req.url.indexOf('/api') === 0) { req.isApi = true; + + if (config.api.requireSSL) { + if (!req.secure && (String(req.headers['x-forwarded-proto']).toLowerCase() !== "https") ) { + res.status(403); // forbidden + res.json({ error: 'All API requests must be made over SSL/TLS' }); + return; + } + } + if (req.query.api_key) { apiKey = req.query.api_key; } else if (req.headers.authorization) { From b7e3cbbd1912cab788cc0a336d001e42b0534602 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 21 May 2013 01:07:46 +0100 Subject: [PATCH 12/13] API documentation --- README.markdown | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.markdown b/README.markdown index a3f844f8..2ee97245 100644 --- a/README.markdown +++ b/README.markdown @@ -33,6 +33,24 @@ The original idea spawned from a conversation with another developer in trying t [Version 1](http://1.jsbin.com) of [JS Bin](http://www.flickr.com/photos/remysharp/4284906136) took me the best part of 4 hours to develop [back in 2008](http://remysharp.com/2008/10/06/js-bin-for-collaborative-javascript-debugging/), but [version 2](http://2.jsbin.com) was been rewritten from the ground up and is completely [open source](http://github.com/remy/jsbin). +## API + +A simple REST based API exists for anonymous users if it is enabled in your config.*.json, or can be restricted to registered users with a key specified in `ownership.ownership_api_key` + +If authentication is required (`allowAnonymous = false`), then an auth_key must be provided as part of an token authorization header or as a querystring with the value `api_key`. Curl examples: + +``` +$ curl http://{{host}}/api/:bin -H "Authorization: token {{token_key}}" +$ curl http://{{host}}/api/:bin?api_key={{token_key}} +``` + +End points are: + +- `GET /api/:bin` - Retrieve the latest version of the bin with that specified ID +- `GET /api/:bin/:rev` - Retrieve the specific version of the bin with the specified ID and revision +- `POST /api/save` - Create a new bin, the body of the post should be URL encoded and contain `html`, `javascript` and `css` parameters +- `POST /api/:bin/save` - Create a new revision for the specified bin, the body of the post should be URL encoded and contain `html`, `javascript` and `css` parameters + ## Build Process JS Bin has been designed to work both online at [jsbin.com](http://jsbin.com) but also in your own locally hosted environment - or even live in your own site (if you do host it as a utility, do let us know by pinging [@js_bin](http://twitter.com/js_bin) on twitter). From b537de085422827887c07d0258a3d3c1df17678e Mon Sep 17 00:00:00 2001 From: Remy Sharp Date: Tue, 11 Jun 2013 12:41:00 +0100 Subject: [PATCH 13/13] Finishing up API, to allow for read only - Added allowReadOnly in config - defaults to true - read only has CORS support - Add handler for :rev route, and autoload the latest (needs more cleaning up) - Allow xhr requests to both /api/x and /abcd/123 (how it originally worked) --- config.default.json | 3 +- lib/handlers/bin.js | 78 ++++++++++++++++++++++++++++++++------------- lib/middleware.js | 2 +- lib/routes.js | 15 +++++++-- 4 files changed, 71 insertions(+), 27 deletions(-) diff --git a/config.default.json b/config.default.json index ae476cd3..3f960b05 100644 --- a/config.default.json +++ b/config.default.json @@ -48,7 +48,8 @@ "report": [] }, "api": { - "allowAnonymous": true, + "allowAnonymous": false, + "allowReadOnly": true, "requireSSL": false }, "blacklist": { diff --git a/lib/handlers/bin.js b/lib/handlers/bin.js index 9301de6a..57cc8b1c 100644 --- a/lib/handlers/bin.js +++ b/lib/handlers/bin.js @@ -8,6 +8,7 @@ var async = require('asyncjs'), apiConfig = require('../config').api || {}; scripts = require('../../scripts.json'), processors = require('../processors'), + _ = require('underscore'), Observable = utils.Observable; module.exports = Observable.extend({ @@ -72,19 +73,44 @@ module.exports = Observable.extend({ getBin: function (req, res, next) { this.render(req, res, req.bin); }, - apiGetBin: function (req, res, next) { - var params = { id: req.bin.url }; + // creates a object that's API request friendly - ie. gets rendered + // output if there's a processor and hides internal fields + apiPrepareBin: function (bin) { + var out = _.pick(bin, + 'html', + 'javascript', + 'css', + 'original_html', + 'original_javascript', + 'original_css', + 'created', + 'last_updated' + ); + + out.permalink = this.helpers.urlForBin(bin, true); + + if (bin.settings && bin.settings.processors) { + out.processors = bin.settings.processors; + } + + // note: if there's no processor, then the "original_*" fields + + return out; + }, + loadBinRevision: function (req, res, next) { if (req.param('rev')) { - res.json(req.bin); + next(); } else { - this.models.bin.latest(params, function(err, result) { - if (err) { - return next(err); - } - res.json(result); + this.models.bin.latest({ id: req.bin.url }, function(err, bin) { + req.bin = bin; + next(); }); } }, + apiGetBin: function (req, res, next) { + this.applyProcessors(req.bin); + res.json(this.apiPrepareBin(req.bin)); + }, live: function (req, res, next) { req.live = true; next(); @@ -105,13 +131,15 @@ module.exports = Observable.extend({ next(err); } - if (formatted) { + if (req.xhr) { + res.json(this.apiPrepareBin(req.bin)); + } else if (formatted) { res.send(formatted); } else { res.contentType('js'); res.send(req.bin.javascript); } - }); + }.bind(this)); }, // TODO decide whether this is used anymore getBinSource: function (req, res) { @@ -750,20 +778,26 @@ module.exports = Observable.extend({ } }); }, - formatPreview: function (bin, options, fn) { - (function () { - if (bin.settings && bin.settings.processors) { - for (var panel in bin.settings.processors) { - var processorName = bin.settings.processors[panel], - processor = processors[processorName], - code = bin[panel]; - if (processor) { - bin['original_' + panel] = code; - bin[panel] = processor(code); - } + // applies the processors to the bin and generates the html, js, etc + // based on the appropriate processor. Used in the previews and the API + // requests. + applyProcessors: function (bin) { + if (bin.settings && bin.settings.processors) { + for (var panel in bin.settings.processors) { + var processorName = bin.settings.processors[panel], + processor = processors[processorName], + code = bin[panel]; + if (processor) { + bin['original_' + panel] = code; + bin[panel] = processor(code); } } - })(); + } + + // nothing returned as it updates the bin object + }, + formatPreview: function (bin, options, fn) { + this.applyProcessors(bin); var formatted = bin.html || '', helpers = this.helpers, diff --git a/lib/middleware.js b/lib/middleware.js index 95f25c27..3aa47b89 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -204,7 +204,7 @@ module.exports = { apiKey = req.headers.authorization.replace(/token\s/,''); } - if (config.api.allowAnonymous) { + if (config.api.allowAnonymous || (config.api.allowReadOnly && req.method === 'GET')) { next(); } else { if (!apiKey) { diff --git a/lib/routes.js b/lib/routes.js index c1d5bca3..e1bc5fa7 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -48,6 +48,7 @@ module.exports = function (app) { // Load the bin from the store when encountered in the url. Also handles the // "latest" url action. app.param('bin', binHandler.loadBin); + app.param('rev', binHandler.loadBinRevision); app.param('name', sessionHandler.loadUser); // Set up the routes. @@ -56,9 +57,17 @@ module.exports = function (app) { app.post('/', binHandler.getFromPost); // API methods - app.get('/api/:bin/:rev?', binHandler.apiGetBin); - app.post('/api/save', binHandler.apiCreateBin); - app.post('/api/:bin/save', binHandler.apiCreateRevision); + var allowAnonymous = app.settings['api allowAnonymous'], + allowReadOnly = app.settings['api allowReadOnly']; + + if (allowAnonymous || allowReadOnly) { + app.get('/api/:bin/:rev?', binHandler.apiGetBin); + } + + if (allowAnonymous) { + app.post('/api/save', binHandler.apiCreateBin); + app.post('/api/:bin/save', binHandler.apiCreateRevision); + } // Login/Create account. app.post('/sethome', sessionHandler.routeSetHome);