@runnable/api-client
Version:
Runnable API Client
435 lines (398 loc) • 14.2 kB
JavaScript
/*global window:true */
'use strict';
var Boom = require('boom');
var exists = require('101/exists');
var find = require('101/find');
var hasProps = require('101/has-properties');
var isBrowser = typeof window !== 'undefined';
var isFunction = require('101/is-function');
var isString = require('101/is-string');
var keypather = require('keypather')();
var parseUrl = require('url').parse;
var Promise = require('bluebird');
var qs = require('querystring');
var util = require('util');
var ApiClient = require('../api-client');
var Base = require('./base');
var collectionStore = require('../stores/collection-store');
var modelStore = require('../stores/model-store');
var Socket = require('../socket');
var urlJoin = require('../url-join');
var masterHostnameErrMessage = 'not a master hostname';
function isMasterHostnameErr (err) {
return keypather.get(err, 'output.payload.message') === masterHostnameErrMessage;
}
function isInvalidHostnameErr (err) {
return keypather.get(err, 'output.statusCode') === 400 &&
keypather.get(err, 'data.errorCode') === 'INVALID_HOSTNAME';
}
module.exports = User;
function User (attrsOrHost, opts) {
if (!console.trace) {
console.trace = function () {
var args = Array.prototype.slice.call(arguments);
args.unshift('Trace:');
console.log.apply(console, args);
console.log(new Error('trace').stack);
};
}
var attrs, host;
if (isString(attrsOrHost)) {
host = attrsOrHost;
attrs = {};
}
else {
attrs = attrsOrHost;
host = null;
}
this.host = host || 'http://api.runnable.com';
this.opts = opts || {};
// if (!this.opts.userContentDomain) {
// throw new Error('user opts.userContentDomain is required');
// }
this.opts.client = this.opts.client || new ApiClient(this.host, this.opts.requestDefaults);
Base.call(this, attrs, this.opts);
}
util.inherits(User, Base);
User.prototype.urlPath = 'users';
User.prototype.githubLogin = function (accessToken, cb) {
if (isBrowser) {
window.redirect('/auth/github');
}
else {
var self = this;
if (typeof accessToken === 'function') {
cb = accessToken;
cb(Boom.badRequest('you need an accessToken to login with GitHub'));
} else {
self.client.post('/auth/github/token',
{ json: { accessToken: accessToken }},
function (err, res, body) {
if (err) { return cb(err); }
else if (res.statusCode !== 200) {
var bodyMessage = keypather.get(body, 'message') || body;
if (res.statusCode >= 400) {
return cb(Boom.create(res.statusCode, bodyMessage));
}
if (res.statusCode === 302) {
var headerLocation = keypather.get(res, 'headers.location');
if (headerLocation && headerLocation.match(/whitelist.*false/i)) {
return cb(Boom.unauthorized('User was authenticated, but does not belong to any whitelisted org'));
}
}
var errorMessage = 'Error logging in (HTTP status code: ' + res.statusCode + ' )';
if (bodyMessage) {
errorMessage += ' (Message: ' + bodyMessage + ')';
}
return cb(Boom.create(500, errorMessage));
}
self.id('me');
self.fetch(cb);
var cookie = keypather.get(res, 'headers["set-cookie"][0]');
if (cookie) {
self.connectSid = cookie.split(';')[0].split('=')[1];
}
});
return this; // return model
}
}
};
User.prototype.parse = function (attrs) {
// Special property only exposed in /user/me and not exposed in the /user?githugOrgName route
if (keypather.get(this, 'attrs.bigPoppaUser') && !keypather.get(attrs, 'bigPoppaUser')) {
attrs.bigPoppaUser = this.attrs.bigPoppaUser;
}
// This is the ****ing worst, probably should do a Object.assign
if (keypather.get(this, 'attrs.userOptions') && !keypather.get(attrs, 'userOptions')) {
attrs.userOptions = this.attrs.userOptions;
}
return attrs;
};
/**
* Creates the socket connection for updates from api, mainly for instances
* DO NOT CALL THIS AT THE BEGINNING OF THE APP. IF THE USER IS NOT AUTHED,
* IT WILL CAUSE MASSIVE FAILURES LOGGING IN. SO DON'T!
* @returns {Object|*} socket
*/
User.prototype.createSocket = function (options) {
if (!this.socket) {
this.socket = new Socket(this.host, options);
return this.socket;
}
};
User.prototype.logout = function (cb) {
this.client.delete('/auth', function (err, res, body) {
modelStore.reset();
collectionStore.reset();
if (err) {
cb(err);
}
else if (res.statusCode !== 200) {
cb(Boom.create(res.statusCode, body.message || body));
}
else {
cb(err, body, res.statusCode);
}
});
};
/**
* should redirect user to API for login
* @param {object} redirect url to redirect to after auth
* format: <protocol>/<host>:<port>
*/
User.prototype.getGithubAuthUrl = function (redirect, forceLogin) {
var opts = {
requiresToken: 'yes',
redirect: redirect
};
if (forceLogin) {
opts.forceLogin = 'yes';
}
var query = qs.stringify(opts);
return urlJoin(this.host, 'auth/github') + '?' + query;
};
User.prototype.isOwnerOf = function (model) {
if (model.toJSON) {
model = model.toJSON();
}
if (!model.owner) {
throw new Error("model doesn't have an owner property");
}
if (!this.attrs.accounts) {
throw new Error("user doesn't have account info");
}
return model.owner.github === this.attrs.accounts.github.id;
};
User.prototype.gravitar = function () {
return this.attrs.gravatar;
};
User.prototype.oauthName = function () {
return keypather.get(this, 'attrs.accounts.github.username');
};
User.prototype.oauthId = function () {
return keypather.get(this.attrs, 'accounts.github.id');
};
// utils
/**
* fetch internal ip for url (used by charon)
* will return null if referer not found
* @param {String} hostname master pod instance url
* @param {String} containerLocalIp local ip address for the container making
* the request.
* @param {String} localDockHost local ip address of the dock host making this request
* @param {Function} cb callback(err, networkIp)
*/
User.prototype.fetchInternalIpForHostname = function (hostname, containerLocalIp, localDockHost, cb) {
var self = this;
// masterInstance exists
var refererQuery = {
'container.inspect.NetworkSettings.IPAddress': containerLocalIp,
'container.dockerHost': localDockHost
};
self._fetchInstanceAndDepWithHostname(refererQuery, hostname, function (err, dep, instance) {
if (err) {
if (isInvalidHostnameErr(err)) {
err.errorType = 'invalid-hostname';
}
return cb(err);
}
// Check if the dependency has a host ip
if (hasHostIp(dep)) {
return cb(null, dep.attrs.network.hostIp);
}
// no dependency found, check if in isolation so we can route if applicable
self._fetchMasterInstanceByHostname(hostname, keypather.get(instance, 'attrs.isolated'), function (err, masterInstance) {
if (err) {
if (isMasterHostnameErr(err)) {
err.errorType = 'not-master-hostname';
}
else if (isInvalidHostnameErr(err)) {
err.errorType = 'invalid-hostname';
}
return cb(err);
}
// Check the master instance for a host ip
if (hasHostIp(masterInstance)) {
return cb(null, masterInstance.attrs.network.hostIp);
}
// If neither have one then error gracefully
cb(new Error('Master instance missing `network.hostIp`'));
});
});
function hasHostIp(instance) {
return exists(keypather.get(instance, 'attrs.network.hostIp'));
}
};
/**
* check to see if requested url is a direct url
* if it is, set mapping to that instance
* if it is not, error with 404
* directUrls are formated like this
* <branch>-<repository>-<environment>-<organization>.runnableapp.com
* we cannot rely on '-' being a delimiter therefore branch can not be parsed
* search all permutations subtracting the last 2 `-` since they are guaranteed to exist
* @param {String} directUrl format: <protocol>://<host>:<port>
* @param {Function} cb callback(err)
*/
User.prototype.checkAndSetIfDirectUrl = function (directUrl, cb) {
var self = this;
var branchPerm = [];
var carry = '';
var hostname = parseUrl(directUrl).hostname;
var parts = hostname.split('-');
// remove last 2
parts.splice(parts.length - 2, parts.length);
// create all possible permutations of branch
parts.forEach(function(value) {
carry += value.toLowerCase();
branchPerm.push(carry);
});
var instances = self.fetchInstances({
name: {
$in: branchPerm
}
}, function (err) {
if (err) { return cb(err); }
if (instances.models.length > 1) {
return cb(Boom.create(404, 'there are multiple instances for this url', parts));
}
if (instances.models.length <= 0) {
return cb(Boom.create(404, 'there is no instance for this url', parts));
}
var instance = instances.models[0];
self.createRoute({
srcHostname: hostname,
destInstanceId: instance.attrs._id
}, cb);
});
};
/**
* gets a backend based on the user mapping model
* @param {[type]} url key to look for in mapping
* @param {Function} cb (err, host)
* host format: <protocol>://<host>:<port>
* will always exist
*/
User.prototype.getBackendFromUserMapping = function (url, cb) {
var self = this;
var parsedUrl = parseUrl(url);
self.fetchRoutes(function (err, mappings) {
if (err) { return cb(err); }
var targetMapping = find(mappings, hasProps({srcHostname: parsedUrl.hostname}));
if (!targetMapping) { return cb(Boom.create(404, 'no mapping for this url')); }
self.fetchInstances({
_id: targetMapping.destInstanceId,
githubUsername: self.attrs.accounts.github.username
}, function (err, instances) {
if (err) { return cb(err); }
if (instances.models.length <= 0) {
return cb(Boom.create(404, 'mapped instances no longer exist'));
}
var instance = instances.models[0];
validateAndCbBackend(instance, parseUrl.port, cb);
});
});
};
/**
* private function that gets the backend url of an instance and verifies the port is exposed
* @param {Object} instance instance model
* @param {Function} cb callback
*/
function validateAndCbBackend (instance, urlPort, cb) {
var backendUrl = instance.dockerUrlForPort(urlPort);
var err = validateContainer(instance.attrs.container);
if (err) {
return cb(err); }
if (!backendUrl) {
err = Boom.create(400, 'port not exposed', urlPort);
return cb(err);
}
cb(null, backendUrl);
}
/**
* validate container existence
* @param {Object} container instance.attrs.container - instance container json
* @return {Error} err returns err only if container does not exist
*/
function validateContainer (container) {
var err;
if (!container || container.error) {
err = Boom.create(504, 'instance is down (no container)', container.error);
}
else if (container.inspect.error) {
err = Boom.create(503, 'instance inspect failed (try again)', container.inspect.error);
}
return err;
}
User.prototype.fetchInstancesPromise = function (query) {
var self = this;
return Promise.fromCallback(function (cb) {
var instances = self.fetchInstances(query, function (err) {
cb(err, instances)
});
});
};
/**
* fetch master instance by hostname. If refer is isolated, check isolation group.
* container not in isolation => default to master
* has a container in isolation => return error
* @param {String} hostname hostname of the instance's url
* @param {Boolean} isolated - isolated property of refer instance(s)
* @param {Function} cb callback(err, instanceModel) // Never calls back a null model
* @throws {Boom.400} throws when no instance has been found
* @throws {Error} from fetchInstances
*/
User.prototype._fetchMasterInstanceByHostname = function (hostname, isolated, cb) {
if (isFunction(isolated)) {
cb = isolated;
isolated = null;
}
var opts = {
hostname: hostname
};
// If we are isolated then check the isolated group
if (isolated) {
opts.isolated = isolated;
} else {
opts.masterPod = true;
}
this.fetchInstancesPromise(opts)
.then(function (instance) {
return keypather.get(instance, 'models[0]');
})
.then(function (instance) {
if (!instance) {
// this error message is used by isMasterHostnameErr method
throw Boom.create(400, masterHostnameErrMessage, { hostname: hostname, opts: opts });
}
return instance;
})
.asCallback(cb);
};
/**
* find an instance with the query and find its dependency with the shared hostname
* @param {Object} query instance url
* @param {String} hostname instance dependency hostname (shared with master pod)
* @param {Function} cb callback(err, dep) // can callback null dep
*/
User.prototype._fetchInstanceAndDepWithHostname = function (query, hostname, cb) {
// First, use the query to find THIS instance, ie the container making the query
var instances = this.fetchInstances(query, function (err) {
if (err) { return cb(err); }
var instance = instances.models[0];
if (!instance) {
return cb(Boom.create(404, 'referer instance not found', {
query: query,
hostname: hostname
}));
}
var query2 = { hostname: hostname };
// Using that instance, fetch it's dependencies that match the target hostname
var deps = instance.fetchDependencies(query2, function (err) {
// This should be the specific instance THIS container is attempting to go to
cb(err, deps.models[0], instance);
});
});
};
require('../extend-with-factories')(User, '');
require('../extend-with-factories')(User, 'user');