Merge branch 'ably-forks-api'

This commit is contained in:
Remy Sharp 2013-06-11 12:43:10 +01:00
commit e34b4a97dd
17 changed files with 325 additions and 61 deletions

View File

@ -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).

View File

@ -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 */;

View File

@ -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`);

View File

@ -0,0 +1,3 @@
ALTER TABLE ownership
ADD COLUMN api_key VARCHAR(255) NULL,
KEY `ownership_api_key` (`expires`);

View File

@ -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`);

View File

@ -50,6 +50,11 @@
"errors": [],
"report": []
},
"api": {
"allowAnonymous": false,
"allowReadOnly": true,
"requireSSL": false
},
"blacklist": {
"html": ["processform.cgi", "habbo.com"],
"css": [],
@ -66,6 +71,7 @@
"activity",
"all",
"announcements",
"api",
"api_rules",
"api_terms",
"apirules",

View File

@ -149,7 +149,8 @@ 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\//] }));
app.use(middleware.api({ app: app }));
app.use(middleware.subdomain(app));
app.use(middleware.noslashes());
app.use(middleware.ajax());

View File

@ -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);
},

View File

@ -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) {

View File

@ -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`=?",

View File

@ -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;
@ -406,7 +420,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;
},

View File

@ -5,8 +5,10 @@ var async = require('asyncjs'),
errors = require('../errors'),
custom = require('../custom'),
blacklist = require('../blacklist'),
apiConfig = require('../config').api || {};
scripts = require('../../scripts.json'),
processors = require('../processors'),
_ = require('underscore'),
Observable = utils.Observable;
module.exports = Observable.extend({
@ -71,6 +73,44 @@ module.exports = Observable.extend({
getBin: function (req, res, next) {
this.render(req, res, req.bin);
},
// 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')) {
next();
} else {
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();
@ -91,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) {
@ -211,6 +253,25 @@ 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.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
@ -235,6 +296,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 = {},
@ -376,53 +478,61 @@ module.exports = Observable.extend({
},
notFound: function (req, res, next) {
var files = this.defaultFiles(),
_this = this;
_this = this,
errorMessage;
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 (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'); }
res.json({ error: errorMessage });
} 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,
@ -668,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,

View File

@ -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.isApi) {
res.contentType('html');
res.send(err.toHTMLString());
} else if (req.accepts(['json'])) {
} else if (req.accepts(['json']) || req.isApi) {
res.json(err);
} else {
res.contentType('txt');

View File

@ -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 = {
@ -96,15 +98,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) {
@ -159,5 +179,56 @@ module.exports = {
}
next();
};
},
// detect if this is an API request and add flag isApi to the request object
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 (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) {
apiKey = req.headers.authorization.replace(/token\s/,'');
}
if (config.api.allowAnonymous || (config.api.allowReadOnly && req.method === 'GET')) {
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();
}
};
}
};

View File

@ -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);
},

View File

@ -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.
@ -55,6 +56,19 @@ module.exports = function (app) {
app.get('/gist/*', binHandler.getDefault);
app.post('/', binHandler.getFromPost);
// API methods
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);
app.post('/logout', sessionHandler.logoutUser);
@ -87,7 +101,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.

View File

@ -27,6 +27,7 @@ var methods = [
'archiveBin',
'getUser',
'getUserByEmail',
'getUserByApiKey',
'setUser',
'touchLogin',
'touchOwners',