diff --git a/boot/worker.js b/boot/worker.js
index 3b1223a..78167de 100644
--- a/boot/worker.js
+++ b/boot/worker.js
@@ -149,9 +149,10 @@ module.exports.create = function () {
process.on('unhandledRejection', function (err) {
// this should always throw
// (it means somewhere we're not using bluebird by accident)
- console.error('[caught] [unhandledRejection]');
- console.error(Object.keys(err));
- console.error(err);
+ console.error('[caught unhandledRejection]:', err.message || '');
+ Object.keys(err).forEach(function (key) {
+ console.log('\t'+key+': '+err[key]);
+ });
console.error(err.stack);
});
process.on('rejectionHandled', function (msg) {
diff --git a/lib/apis.js b/lib/apis.js
index 834c649..76aba6e 100644
--- a/lib/apis.js
+++ b/lib/apis.js
@@ -8,7 +8,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
var express = require('express-lazy');
var fs = PromiseA.promisifyAll(require('fs'));
var path = require('path');
- var localCache = { rests: {}, pkgs: {} };
+ var localCache = { rests: {}, pkgs: {}, assets: {} };
var promisableRequest = require('./common').promisableRequest;
var rejectableRequest = require('./common').rejectableRequest;
var crypto = require('crypto');
@@ -32,7 +32,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
}
*/
- function isThisClientAllowedToUseThisPkg(myConf, clientUrih, pkgId) {
+ function isThisClientAllowedToUseThisPkg(req, myConf, clientUrih, pkgId) {
var appApiGrantsPath = path.join(myConf.appApiGrantsPath, clientUrih);
return fs.readFileAsync(appApiGrantsPath, 'utf8').then(function (text) {
@@ -51,12 +51,23 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
return true;
}
- if (clientUrih === ('api.' + xconfx.setupDomain) && 'org.oauth3.consumer' === pkgId) {
+ console.log('#################################################');
+ console.log('assets.' + xconfx.setupDomain);
+ console.log('assets.' + clientUrih);
+ console.log(req.clientAssetsUri);
+ console.log(pkgId);
+
+ if (req.clientAssetsUri === ('assets.' + clientUrih) && -1 !== [ 'session', 'session@oauth3.org', 'azp@oauth3.org', 'issuer@oauth3.org' ].indexOf(pkgId)) {
// fallthrough
return true;
- } else {
- return null;
}
+
+ if (clientUrih === ('api.' + xconfx.setupDomain) && -1 !== ['org.oauth3.consumer', 'azp@oauth3.org', 'oauth3.org'].indexOf(pkgId)) {
+ // fallthrough
+ return true;
+ }
+
+ return null;
});
}
@@ -150,7 +161,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
function accountRequired(req, res, next) {
// if this already has auth, great
- if (req.oauth3.ppid) {
+ if (req.oauth3.ppid && req.oauth3.accountIdx) {
next();
return;
}
@@ -211,10 +222,764 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
rejectableRequest(req, res, promise, "[walnut@daplie.com] required account (not /public)");
}
- function loadRestHelper(myConf, clientUrih, pkgId) {
- var pkgPath = path.join(myConf.restPath, pkgId);
+ function grantsRequired(grants) {
+ if (!Array.isArray(grants)) {
+ throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])");
+ }
+
+ if (!grants.length) {
+ return function (req, res, next) {
+ next();
+ };
+ }
+
+ return function (req, res, next) {
+ var tokenScopes;
+
+ if (!(req.oauth3 || req.oauth3.token)) {
+ // TODO some error generator for standard messages
+ res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } });
+ return;
+ }
+ var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants;
+ if ('string' !== typeof scope) {
+ res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } });
+ return;
+ }
+
+ tokenScopes = scope.split(/[,\s]+/mg);
+ if (-1 !== tokenScopes.indexOf('*')) {
+ // has full account access
+ next();
+ return;
+ }
+
+ // every grant in the array must be present, though some grants can be satisfied
+ // by multiple scopes.
+ var missing = grants.filter(function (grant) {
+ return !grant.split('|').some(function (scp) {
+ return tokenScopes.indexOf(scp) !== -1;
+ });
+ });
+ if (missing.length) {
+ res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } });
+ return;
+ }
+
+ next();
+ };
+ }
+ function loadRestHelperApi(myConf, clientUrih, pkg, pkgId, pkgPath) {
var pkgLinks = [];
pkgLinks.push(pkgId);
+ var pkgRestApi;
+ var pkgDeps = {};
+ var myApp;
+ var pkgPathApi;
+
+ pkgPathApi = pkgPath;
+ if (pkg.walnut) {
+ pkgPathApi = path.join(pkgPath, pkg.walnut);
+ }
+ pkgRestApi = require(pkgPathApi);
+
+ Object.keys(apiDeps).forEach(function (key) {
+ pkgDeps[key] = apiDeps[key];
+ });
+ Object.keys(apiFactories).forEach(function (key) {
+ pkgDeps[key] = apiFactories[key];
+ });
+
+ // TODO pull db stuff from package.json somehow and pass allowed data models as deps
+ //
+ // how can we tell which of these would be correct?
+ // deps.memstore = apiFactories.memstoreFactory.create(pkgId);
+ // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId);
+ // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId + pkgId);
+
+ // let's go with this one for now and the api can choose to scope or not to scope
+ pkgDeps.memstore = apiFactories.memstoreFactory.create(pkgId);
+
+ myApp = express();
+ myApp.handlePromise = promisableRequest;
+ myApp.handleRejection = rejectableRequest;
+ myApp.grantsRequired = grantsRequired;
+
+ function getSitePackageStoreProp(otherPkgId) {
+ var restPath = path.join(myConf.restPath, otherPkgId);
+ var apiPath = path.join(myConf.apiPath, otherPkgId);
+ var dir;
+
+ // TODO usage package.json as a falback if the standard location is not used
+ try {
+ dir = require(path.join(apiPath, 'models.js'));
+ } catch(e) {
+ dir = require(path.join(restPath, 'models.js'));
+ }
+
+ return getSiteStore(clientUrih, otherPkgId, dir);
+ }
+
+ function attachOauth3(req, res, next) {
+ return getSitePackageStoreProp('issuer@oauth3.org').then(function (Models) {
+ return require('./oauth3').attachOauth3(Models, req, res, next);
+ });
+ }
+ myApp.use('/', attachOauth3);
+
+ // TODO delete these caches when config changes
+ var _stripe;
+ var _stripe_test;
+ var _mandrill;
+ var _mailchimp;
+ var _twilio;
+ var _get_response;
+ myApp.use('/', function preHandler(req, res, next) {
+ //if (xconfx.debug) { console.log('[api.js] loading handler prereqs'); }
+ return getSiteConfig(clientUrih).then(function (siteConfig) {
+ //if (xconfx.debug) { console.log('[api.js] loaded handler site config'); }
+
+ // Use getSiteCapability('email@daplie.com') instead
+ Object.defineProperty(req, 'getSiteMailer' /*deprecated*/, {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSiteMailerProp() {
+ var nodemailer = require('nodemailer');
+ var transport = require('nodemailer-mailgun-transport');
+ //var mailconf = require('../../../com.daplie.mailer/config.mailgun');
+ var mailconf = siteConfig['mailgun.org'];
+ var mailer = PromiseA.promisifyAll(nodemailer.createTransport(transport(mailconf)));
+
+ return mailer;
+ }
+ });
+
+ Object.defineProperty(req, 'getSiteConfig', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSiteConfigProp(section) {
+ // deprecated
+ if ('com.daplie.tel' === section) {
+ section = 'tel@daplie.com';
+ }
+ return PromiseA.resolve((siteConfig || {})[section]);
+ }
+ });
+
+ Object.defineProperty(req, 'getSitePackageConfig', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSitePackageConfigProp() {
+ return getSitePackageConfig(clientUrih, pkgId);
+ }
+ });
+
+ Object.defineProperty(req, 'getSitePackageStore', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: getSitePackageStoreProp
+ });
+
+ Object.defineProperty(req, 'getSiteStore', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSiteStoreProp() {
+ var restPath = path.join(myConf.restPath, pkgId);
+ var apiPath = path.join(myConf.apiPath, pkgId);
+ var dir;
+
+ // TODO usage package.json as a falback if the standard location is not used
+ try {
+ dir = require(path.join(apiPath, 'models.js'));
+ } catch(e) {
+ dir = require(path.join(restPath, 'models.js'));
+ }
+
+ return getSiteStore(clientUrih, pkgId, dir);
+ }
+ });
+
+ /*
+ Object.defineProperty(req, 'getSitePayments', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSitePaymentsProp() {
+ }
+ });
+ */
+ // TODO allow third-party clients stripe ids destination
+ // https://stripe.com/docs/connect/payments-fees
+ Object.defineProperty(req, 'Stripe', {
+ enumerable: true
+ , configurable: false
+ , get: function () {
+ _stripe = _stripe || require('stripe')(siteConfig['stripe.com'].live.secret);
+ return _stripe;
+ }
+ });
+
+ Object.defineProperty(req, 'StripeTest', {
+ enumerable: true
+ , configurable: false
+ , get: function () {
+ _stripe_test = _stripe_test || require('stripe')(siteConfig['stripe.com'].test.secret);
+ return _stripe_test;
+ }
+ });
+
+ Object.defineProperty(req, 'Mandrill', {
+ enumerable: true
+ , configurable: false
+ , get: function () {
+ if (!_mandrill) {
+ var Mandrill = require('mandrill-api/mandrill');
+ _mandrill = new Mandrill.Mandrill(siteConfig['mandrill.com'].apiKey);
+ _mandrill.messages.sendTemplateAsync = function (opts) {
+ return new PromiseA(function (resolve, reject) {
+ _mandrill.messages.sendTemplate(opts, resolve, reject);
+ });
+ };
+ }
+ return _mandrill;
+ }
+ });
+
+ Object.defineProperty(req, 'Mailchimp', {
+ enumerable: true
+ , configurable: false
+ , get: function () {
+ var Mailchimp = require('mailchimp-api-v3');
+ _mailchimp = _mailchimp || new Mailchimp(siteConfig['mailchimp.com'].apiKey);
+ return _mailchimp;
+ }
+ });
+
+ Object.defineProperty(req, 'GetResponse', {
+ enumerable: true
+ , configurable: false
+ , get: function () {
+ if (_get_response) {
+ return _get_response;
+ }
+ _get_response = {
+ saveSubscriber: function (email, opts) {
+ var config = siteConfig['getresponse@daplie.com'];
+ var customFields = [];
+ Object.keys(config.customFields).forEach(function (name) {
+ if (typeof opts[name] !== 'undefined') {
+ customFields.push({
+ customFieldId: config.customFields[name]
+ , value: [ String(opts[name]) ]
+ });
+ }
+ });
+
+ return request({
+ method: 'POST'
+ , url: 'https://api.getresponse.com/v3/contacts'
+ , headers: { 'X-Auth-Token': 'api-key ' + config.apiKey }
+ , json: true
+ , body: {
+ name: opts.name
+ , email: email
+ , ipAddress: opts.ipAddress
+ , campaign: { campaignId: config.campaignId }
+ , customFieldValues: customFields
+ }
+ }).then(function (resp) {
+ if (resp.statusCode === 202) {
+ return;
+ }
+ return PromiseA.reject(resp.body.message);
+ });
+ }
+ };
+
+ return _get_response;
+ }
+ });
+
+ var Twilio = require('twilio');
+ function twilioTel(/*opts*/) {
+ if (_twilio) {
+ return apiDeps.Promise.resolve(_twilio);
+ }
+
+ _twilio = new Twilio.RestClient(
+ siteConfig['twilio.com'].live.id
+ , siteConfig['twilio.com'].live.auth
+ );
+ return apiDeps.Promise.resolve(_twilio);
+ }
+
+ // TODO shared memory db
+ var mailgunTokens = {};
+ function validateMailgun(apiKey, timestamp, token, signature) {
+ // https://gist.github.com/coolaj86/81a3b61353d2f0a2552c
+ // (realized later)
+ // HAHA HAHA HAHAHAHAHA this is my own gist... so much more polite attribution
+ var scmp = require('scmp')
+ , mailgunExpirey = 15 * 60 * 1000
+ , mailgunHashType = 'sha256'
+ , mailgunSignatureEncoding = 'hex'
+ ;
+ var actual
+ , adjustedTimestamp = parseInt(timestamp, 10) * 1000
+ , fresh = (Math.abs(Date.now() - adjustedTimestamp) < mailgunExpirey)
+ ;
+
+ if (!fresh) {
+ console.error('[mailgun] Stale Timestamp: this may be an attack');
+ console.error('[mailgun] However, this is most likely your fault\n');
+ console.error('[mailgun] run `ntpdate ntp.ubuntu.com` and check your system clock\n');
+ console.error('[mailgun] System Time: ' + new Date().toString());
+ console.error('[mailgun] Mailgun Time: ' + new Date(adjustedTimestamp).toString(), timestamp);
+ console.error('[mailgun] Delta: ' + (Date.now() - adjustedTimestamp));
+ return false;
+ }
+
+ if (mailgunTokens[token]) {
+ console.error('[mailgun] Replay Attack');
+ return false;
+ }
+
+ mailgunTokens[token] = true;
+
+ setTimeout(function () {
+ delete mailgunTokens[token];
+ }, mailgunExpirey + (5 * 1000));
+
+ actual = crypto.createHmac(mailgunHashType, apiKey)
+ .update(new Buffer(timestamp + token, 'utf8'))
+ .digest(mailgunSignatureEncoding)
+ ;
+ return scmp(signature, actual);
+ }
+
+ function mailgunMail(/*opts*/) {
+ return apiDeps.Promise.resolve(req.getSiteMailer());
+ }
+ function getResponseList() {
+ return apiDeps.Promise.resolve(req.GetResponse);
+ }
+
+ // Twilio Parameters are often 26 long
+ var bodyParserTwilio = require('body-parser').urlencoded({ limit: '4kb', parameterLimit: 100, extended: false });
+ // Mailgun has something like 50 parameters
+ var bodyParserMailgun = require('body-parser').urlencoded({ limit: '1024kb', parameterLimit: 500, extended: false });
+ function bodyMultiParserMailgun (req, res, next) {
+ var multiparty = require('multiparty');
+ var form = new multiparty.Form();
+
+ form.parse(req, function (err, fields/*, files*/) {
+ if (err) {
+ console.error('Error');
+ console.error(err);
+ res.end("Couldn't parse form");
+ return;
+ }
+
+ var body;
+ req.body = req.body || {};
+ Object.keys(fields).forEach(function (key) {
+ // TODO what if there were two of something?
+ // (even though there won't be)
+ req.body[key] = fields[key][0];
+ });
+ body = req.body;
+
+ next();
+ });
+ }
+
+ function daplieTel() {
+ return twilioTel().then(function (twilio) {
+ function sms(opts) {
+ // opts = { to, from, body }
+ return new apiDeps.Promise(function (resolve, reject) {
+ twilio.sendSms(opts, function (err, resp) {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ resolve(resp);
+ });
+ });
+ }
+
+ return {
+ sms: sms
+ , mms: function () { throw new Error('MMS Not Implemented'); }
+ };
+ });
+ }
+
+ var settingsPromise = PromiseA.resolve();
+ function manageSiteSettings(section) {
+
+ var submanager;
+ var manager = {
+ set: function (section, value) {
+ if ('email@daplie.com' === section) {
+ section = 'mailgun.org';
+ }
+
+ settingsPromise = settingsPromise.then(function () {
+ return manager.get().then(function () {
+ siteConfig[section] = value;
+
+ var siteConfigPath = path.join(xconfx.appConfigPath, clientUrih);
+ return mkdirpAsync(siteConfigPath).then(function () {
+ return fs.writeFileAsync(path.join(siteConfigPath, 'config.json'), JSON.stringify(siteConfig), 'utf8');
+ });
+ });
+ });
+ return settingsPromise;
+ }
+ , get: function (section) {
+ if ('email@daplie.com' === section) {
+ section = 'mailgun.org';
+ }
+
+ settingsPromise = settingsPromise.then(function () {
+ return getSiteConfig(clientUrih).then(function (_siteConfig) {
+ siteConfig = _siteConfig;
+ return PromiseA.resolve((_siteConfig || {})[section]);
+ });
+ });
+ return settingsPromise;
+ }
+ };
+
+ submanager = manager;
+ if (section) {
+ submanager = {
+ set: function (value) {
+ return manager.set(section, value);
+ }
+ , get: function () {
+ return manager.get(section);
+ }
+ };
+ }
+
+ return apiDeps.Promise.resolve(submanager);
+ }
+
+ var caps = {
+ //
+ // Capabilities for APIs
+ //
+ 'settings.site@daplie.com': manageSiteSettings
+ , 'email@daplie.com': mailgunMail // whichever mailer
+ , 'mailer@daplie.com': mailgunMail // whichever mailer
+ , 'mailgun@daplie.com': mailgunMail // specifically mailgun
+ , 'tel@daplie.com': daplieTel // whichever telephony service
+ , 'twilio@daplie.com': twilioTel // specifically twilio
+ , 'com.daplie.tel.twilio': twilioTel // deprecated alias
+
+ , 'getresponse@daplie.com': getResponseList
+ //
+ // Webhook Parsers
+ //
+ //, 'mailgun.urlencoded@daplie.com': function (req, res, next) { ... }
+ , 'mailgun.parsers@daplie.com': function (req, res, next) {
+ var chunks = [];
+
+ req.on('data', function (chunk) {
+ chunks.push(chunk);
+ });
+ req.on('end', function () {
+ });
+
+ function verify() {
+ var body = req.body;
+ var mailconf = siteConfig['mailgun.org'];
+
+ if (!body.timestamp) {
+ console.log('mailgun parser req.headers');
+ console.log(req.headers);
+ chunks.forEach(function (datum) {
+ console.log('Length:', datum.length);
+ //console.log(datum.toString('utf8'));
+ });
+ console.log('weird body');
+ console.log(body);
+ }
+
+ if (!validateMailgun(mailconf.apiKey, body.timestamp, body.token, body.signature)) {
+ console.error('Request came, but not from Mailgun');
+ console.error(req.url);
+ console.error(req.headers);
+ res.send({ error: { message: 'Invalid signature. Are you even Mailgun?' } });
+ return;
+ }
+
+ next();
+ }
+
+ if (/urlencoded/.test(req.headers['content-type'])) {
+ console.log('urlencoded');
+ bodyParserMailgun(req, res, verify);
+ }
+ else if (/multipart/.test(req.headers['content-type'])) {
+ console.log('multipart');
+ bodyMultiParserMailgun(req, res, verify);
+ }
+ else {
+ console.log('no parser');
+ next();
+ }
+ }
+ , 'twilio.urlencoded@daplie.com': function (req, res, next) {
+ // TODO null for res and Promise instead of next?
+ return bodyParserTwilio(req, res, function () {
+ var signature = req.headers['x-twilio-signature'];
+ var auth = siteConfig['twilio.com'].live.auth;
+ var fullUrl = 'https://' + req.headers.host + req._walnutOriginalUrl;
+ var validSig = Twilio.validateRequest(auth, signature, fullUrl, req.body);
+ /*
+ console.log('Twilio Signature Check');
+ console.log('auth', auth);
+ console.log('sig', signature);
+ console.log('fullUrl', fullUrl);
+ console.log(req.body);
+ console.log('valid', validSig);
+ */
+ if (!validSig) {
+ res.statusCode = 401;
+ res.setHeader('Content-Type', 'text/xml');
+ res.end('Invalid signature. Are you even Twilio?');
+ return;
+ }
+ // TODO session via db req.body.CallId req.body.smsId
+ next();
+ });
+ }
+ };
+ req.getSiteCapability = function (capname, opts, b, c) {
+ if (caps[capname]) {
+ return caps[capname](opts, b, c);
+ }
+ if (siteConfig[capname]) {
+ var service = siteConfig[capname].service || siteConfig[capname];
+ if (caps[service]) {
+ return caps[service](opts, b, c);
+ }
+ }
+ return apiDeps.Promise.reject(
+ new Error("['" + req.clientApiUri + '/' + pkgId + "'] "
+ + "capability '" + capname + "' not implemented")
+ );
+ };
+
+ req._walnutOriginalUrl = req.url;
+ // "/path/api/com.example/hello".replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/') => '/hello'
+ req.url = req.url.replace(/\/api\//, '').replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/');
+ next();
+ });
+ });
+ myApp.use('/public', function preHandler(req, res, next) {
+ // TODO authenticate or use guest user
+ req.isPublic = true;
+ next();
+ });
+ myApp.use('/accounts/:accountId', accountRequiredById);
+ myApp.use('/acl', accountRequired);
+
+ //
+ // TODO handle /accounts/:accountId
+ //
+ return PromiseA.resolve(pkgRestApi.create({
+ etcpath: xconfx.etcpath
+ }/*pkgConf*/, pkgDeps/*pkgDeps*/, myApp/*myApp*/)).then(function (handler) {
+
+ //if (xconfx.debug) { console.log('[api.js] got handler'); }
+ myApp.use('/', function postHandler(req, res, next) {
+ req.url = req._walnutOriginalUrl;
+ next();
+ });
+
+ localCache.pkgs[pkgId] = { pkgId: pkgId, pkg: pkg, handler: handler || myApp, createdAt: Date.now() };
+
+ pkgLinks.forEach(function (pkgLink) {
+ localCache.pkgs[pkgLink] = localCache.pkgs[pkgId];
+ });
+
+ return localCache.pkgs[pkgId];
+ });
+ }
+ function loadRestHelperAssets(myConf, clientUrih, pkg, pkgId, pkgPath) {
+ var myApp;
+ var pkgDeps = {};
+ var pkgRestAssets;
+
+ try {
+ pkgRestAssets = require(path.join(pkgPath, 'assets.js'));
+ } catch(e) {
+ return PromiseA.reject(e);
+ }
+
+ Object.keys(apiDeps).forEach(function (key) {
+ pkgDeps[key] = apiDeps[key];
+ });
+ Object.keys(apiFactories).forEach(function (key) {
+ pkgDeps[key] = apiFactories[key];
+ });
+
+ // TODO pull db stuff from package.json somehow and pass allowed data models as deps
+ //
+ // how can we tell which of these would be correct?
+ // deps.memstore = apiFactories.memstoreFactory.create(pkgId);
+ // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId);
+ // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId + pkgId);
+
+ // let's go with this one for now and the api can choose to scope or not to scope
+ pkgDeps.memstore = apiFactories.memstoreFactory.create(pkgId);
+
+ myApp = express();
+ myApp.handlePromise = promisableRequest;
+ myApp.handleRejection = rejectableRequest;
+ myApp.grantsRequired = grantsRequired;
+
+ function otherGetSitePackageStoreProp(otherPkgId) {
+ var restPath = path.join(myConf.restPath, otherPkgId);
+ var apiPath = path.join(myConf.apiPath, otherPkgId);
+ var dir;
+
+ // TODO usage package.json as a falback if the standard location is not used
+ try {
+ dir = require(path.join(apiPath, 'models.js'));
+ } catch(e) {
+ dir = require(path.join(restPath, 'models.js'));
+ }
+
+ return getSiteStore(clientUrih, otherPkgId, dir);
+ }
+ myApp.use('/', function cookieAttachOauth3(req, res, next) {
+ return otherGetSitePackageStoreProp('issuer@oauth3.org').then(function (Models) {
+ return require('./oauth3').cookieOauth3(Models, req, res, next);
+ });
+ });
+ myApp.use('/', function (req, res, next) {
+ console.log('########################################### session ###############################');
+ console.log('req.url', req.url);
+ console.log('req.oauth3', req.oauth3);
+ next();
+ });
+ function otherAttachOauth3(req, res, next) {
+ return otherGetSitePackageStoreProp('issuer@oauth3.org').then(function (Models) {
+ return require('./oauth3').attachOauth3(Models, req, res, next);
+ });
+ }
+ myApp.post('/assets/issuer@oauth3.org/session', otherAttachOauth3, function (req, res) {
+ console.log('get the session');
+ console.log(req.url);
+ console.log("req.cookies:");
+ console.log(req.cookies);
+ console.log("req.oauth3:");
+ console.log(req.oauth3);
+ res.cookie('jwt', req.oauth3.encodedToken, { domain: req.clientAssetsUri, path: '/assets', httpOnly: true });
+ //req.url;
+ res.send({ success: true });
+ });
+
+ // TODO delete these caches when config changes
+ myApp.use('/', function preHandler(req, res, next) {
+ //if (xconfx.debug) { console.log('[api.js] loading handler prereqs'); }
+ return getSiteConfig(clientUrih).then(function (siteConfig) {
+ //if (xconfx.debug) { console.log('[api.js] loaded handler site config'); }
+
+ Object.defineProperty(req, 'getSiteConfig', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSiteConfigProp(section) {
+ return PromiseA.resolve((siteConfig || {})[section]);
+ }
+ });
+
+ Object.defineProperty(req, 'getSitePackageConfig', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSitePackageConfigProp() {
+ return getSitePackageConfig(clientUrih, pkgId);
+ }
+ });
+
+ Object.defineProperty(req, 'getSiteStore', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: function getSiteStoreProp() {
+ var restPath = path.join(myConf.restPath, pkgId);
+ var apiPath = path.join(myConf.apiPath, pkgId);
+ var dir;
+
+ // TODO usage package.json as a falback if the standard location is not used
+ try {
+ dir = require(path.join(apiPath, 'models.js'));
+ } catch(e) {
+ dir = require(path.join(restPath, 'models.js'));
+ }
+
+ return getSiteStore(clientUrih, pkgId, dir);
+ }
+ });
+
+ req._walnutOriginalUrl = req.url;
+ // "/path/api/com.example/hello".replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/') => '/hello'
+ req.url = req.url.replace(/\/(api|assets)\//, '').replace(/.*\/(api|assets)\//, '').replace(/([^\/]*\/+)/, '/');
+ next();
+ });
+ });
+
+ myApp.use('/public', function preHandler(req, res, next) {
+ // TODO authenticate or use guest user
+ req.isPublic = true;
+ next();
+ });
+ myApp.use('/accounts/:accountId', accountRequiredById);
+ myApp.use('/acl', accountRequired);
+
+ //
+ // TODO handle /accounts/:accountId
+ //
+ function myAppWrapper(req, res, next) {
+ return myApp(req, res, next);
+ }
+ Object.keys(myApp).forEach(function (key) {
+ myAppWrapper[key] = myApp[key];
+ });
+ myAppWrapper.use = function () { myApp.use.apply(myApp, arguments); };
+ myAppWrapper.get = function () { myApp.get.apply(myApp, arguments); };
+ myAppWrapper.post = function () { myApp.use(function (req, res, next) { next(); }); /*throw new Error("assets may not handle POST");*/ };
+ myAppWrapper.put = function () { throw new Error("assets may not handle PUT"); };
+ myAppWrapper.del = function () { throw new Error("assets may not handle DELETE"); };
+ myAppWrapper.delete = function () { throw new Error("assets may not handle DELETE"); };
+ return PromiseA.resolve(pkgRestAssets.create({
+ etcpath: xconfx.etcpath
+ }/*pkgConf*/, pkgDeps/*pkgDeps*/, myAppWrapper)).then(function (assetsHandler) {
+
+ //if (xconfx.debug) { console.log('[api.js] got handler'); }
+ myApp.use('/', function postHandler(req, res, next) {
+ req.url = req._walnutOriginalUrl;
+ next();
+ });
+
+ return assetsHandler || myApp;
+ });
+ }
+ function loadRestHelper(myConf, clientUrih, pkgId) {
+ var pkgPath = path.join(myConf.restPath, pkgId);
// TODO allow recursion, but catch cycles
return fs.lstatAsync(pkgPath).then(function (stat) {
@@ -233,510 +998,17 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
// TODO should not require package.json. Should work with files alone.
return fs.readFileAsync(path.join(pkgPath, 'package.json'), 'utf8').then(function (text) {
var pkg = JSON.parse(text);
- var pkgDeps = {};
- var myApp;
- if (pkg.walnut) {
- pkgPath = path.join(pkgPath, pkg.walnut);
- }
-
- Object.keys(apiDeps).forEach(function (key) {
- pkgDeps[key] = apiDeps[key];
- });
- Object.keys(apiFactories).forEach(function (key) {
- pkgDeps[key] = apiFactories[key];
- });
-
- // TODO pull db stuff from package.json somehow and pass allowed data models as deps
- //
- // how can we tell which of these would be correct?
- // deps.memstore = apiFactories.memstoreFactory.create(pkgId);
- // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId);
- // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId + pkgId);
-
- // let's go with this one for now and the api can choose to scope or not to scope
- pkgDeps.memstore = apiFactories.memstoreFactory.create(pkgId);
-
- console.log('DEBUG pkgPath', pkgPath);
- myApp = express();
- myApp.handlePromise = promisableRequest;
- myApp.handleRejection = rejectableRequest;
- myApp.grantsRequired = function (grants) {
- if (!Array.isArray(grants)) {
- throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])");
- }
-
- if (!grants.length) {
- return function (req, res, next) {
- next();
- };
- }
-
- return function (req, res, next) {
- var tokenScopes;
-
- if (!(req.oauth3 || req.oauth3.token)) {
- // TODO some error generator for standard messages
- res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } });
- return;
- }
- if ('string' !== typeof req.oauth3.token.scp) {
- res.send({ error: { message: "Token must contain a grants string in 'scp'", code: "E_NO_GRANTS" } });
- return;
- }
-
- tokenScopes = req.oauth3.token.scp.split(/[,\s]+/mg);
- if (-1 !== tokenScopes.indexOf('*')) {
- // has full account access
- next();
- return;
- }
-
- // every grant in the array must be present
- if (!grants.every(function (grant) {
- var scopes = grant.split(/\|/g);
- return scopes.some(function (scp) {
- return tokenScopes.some(function (s) {
- return scp === s;
- });
- });
- })) {
- res.send({ error: { message: "Token does not contain valid grants: '" + grants + "'", code: "E_NO_GRANTS" } });
- return;
- }
-
- next();
- };
- };
-
- var _getOauth3Controllers = pkgDeps.getOauth3Controllers = require('oauthcommon/example-oauthmodels').create(
- { sqlite3Sock: xconfx.sqlite3Sock, ipcKey: xconfx.ipcKey }
- ).getControllers;
- //require('oauthcommon').inject(packagedApi._getOauth3Controllers, packagedApi._api, pkgConf, pkgDeps);
- require('oauthcommon').inject(_getOauth3Controllers, myApp/*, pkgConf, pkgDeps*/);
-
- // TODO delete these caches when config changes
- var _stripe;
- var _stripe_test;
- var _mandrill;
- var _mailchimp;
- var _twilio;
- var _get_response;
- myApp.use('/', function preHandler(req, res, next) {
- return getSiteConfig(clientUrih).then(function (siteConfig) {
- Object.defineProperty(req, 'getSiteMailer', {
- enumerable: true
- , configurable: false
- , writable: false
- , value: function getSiteMailerProp() {
- var nodemailer = require('nodemailer');
- var transport = require('nodemailer-mailgun-transport');
- //var mailconf = require('../../../com.daplie.mailer/config.mailgun');
- var mailconf = siteConfig['mailgun.org'];
- var mailer = PromiseA.promisifyAll(nodemailer.createTransport(transport(mailconf)));
-
- return mailer;
- }
- });
-
- Object.defineProperty(req, 'getSiteConfig', {
- enumerable: true
- , configurable: false
- , writable: false
- , value: function getSiteConfigProp(section) {
- // deprecated
- if ('com.daplie.tel' === section) {
- section = 'tel@daplie.com';
- }
- return PromiseA.resolve((siteConfig || {})[section]);
- }
- });
-
- Object.defineProperty(req, 'getSitePackageConfig', {
- enumerable: true
- , configurable: false
- , writable: false
- , value: function getSitePackageConfigProp() {
- return getSitePackageConfig(clientUrih, pkgId);
- }
- });
-
- Object.defineProperty(req, 'getSiteStore', {
- enumerable: true
- , configurable: false
- , writable: false
- , value: function getSiteStoreProp() {
- var restPath = path.join(myConf.restPath, pkgId);
- var apiPath = path.join(myConf.apiPath, pkgId);
- var dir;
-
- // TODO usage package.json as a falback if the standard location is not used
- try {
- dir = require(path.join(apiPath, 'models.js'));
- } catch(e) {
- dir = require(path.join(restPath, 'models.js'));
- }
-
- return getSiteStore(clientUrih, pkgId, dir);
- }
- });
-
- /*
- Object.defineProperty(req, 'getSitePayments', {
- enumerable: true
- , configurable: false
- , writable: false
- , value: function getSitePaymentsProp() {
- }
- });
- */
- // TODO allow third-party clients stripe ids destination
- // https://stripe.com/docs/connect/payments-fees
- Object.defineProperty(req, 'Stripe', {
- enumerable: true
- , configurable: false
- , get: function () {
- _stripe = _stripe || require('stripe')(siteConfig['stripe.com'].live.secret);
- return _stripe;
- }
- });
-
- Object.defineProperty(req, 'StripeTest', {
- enumerable: true
- , configurable: false
- , get: function () {
- _stripe_test = _stripe_test || require('stripe')(siteConfig['stripe.com'].test.secret);
- return _stripe_test;
- }
- });
-
- Object.defineProperty(req, 'Mandrill', {
- enumerable: true
- , configurable: false
- , get: function () {
- if (!_mandrill) {
- var Mandrill = require('mandrill-api/mandrill');
- _mandrill = new Mandrill.Mandrill(siteConfig['mandrill.com'].apiKey);
- _mandrill.messages.sendTemplateAsync = function (opts) {
- return new PromiseA(function (resolve, reject) {
- _mandrill.messages.sendTemplate(opts, resolve, reject);
- });
- };
- }
- return _mandrill;
- }
- });
-
- Object.defineProperty(req, 'Mailchimp', {
- enumerable: true
- , configurable: false
- , get: function () {
- var Mailchimp = require('mailchimp-api-v3');
- _mailchimp = _mailchimp || new Mailchimp(siteConfig['mailchimp.com'].apiKey);
- return _mailchimp;
- }
- });
-
- Object.defineProperty(req, 'GetResponse', {
- enumerable: true
- , configurable: false
- , get: function () {
- if (_get_response) {
- return _get_response;
- }
- _get_response = {
- saveSubscriber: function (email, opts) {
- var config = siteConfig['getresponse@daplie.com'];
- var customFields = [];
- Object.keys(config.customFields).forEach(function (name) {
- if (typeof opts[name] !== 'undefined') {
- customFields.push({
- customFieldId: config.customFields[name]
- , value: [ String(opts[name]) ]
- });
- }
- });
-
- return request({
- method: 'POST'
- , url: 'https://api.getresponse.com/v3/contacts'
- , headers: { 'X-Auth-Token': 'api-key ' + config.apiKey }
- , json: true
- , body: {
- name: opts.name
- , email: email
- , ipAddress: opts.ipAddress
- , campaign: { campaignId: config.campaignId }
- , customFieldValues: customFields
- }
- }).then(function (resp) {
- if (resp.statusCode === 202) {
- return;
- }
- return PromiseA.reject(resp.body.message);
- });
- }
- };
-
- return _get_response;
- }
- });
-
- var Twilio = require('twilio');
- function twilioTel(/*opts*/) {
- if (_twilio) {
- return apiDeps.Promise.resolve(_twilio);
- }
-
- _twilio = new Twilio.RestClient(
- siteConfig['twilio.com'].live.id
- , siteConfig['twilio.com'].live.auth
- );
- return apiDeps.Promise.resolve(_twilio);
- }
-
- // TODO shared memory db
- var mailgunTokens = {};
- function validateMailgun(apiKey, timestamp, token, signature) {
- // https://gist.github.com/coolaj86/81a3b61353d2f0a2552c
- // (realized later)
- // HAHA HAHA HAHAHAHAHA this is my own gist... so much more polite attribution
- var scmp = require('scmp')
- , crypto = require('crypto')
- , mailgunExpirey = 15 * 60 * 1000
- , mailgunHashType = 'sha256'
- , mailgunSignatureEncoding = 'hex'
- ;
- var actual
- , adjustedTimestamp = parseInt(timestamp, 10) * 1000
- , fresh = (Math.abs(Date.now() - adjustedTimestamp) < mailgunExpirey)
- ;
-
- if (!fresh) {
- console.error('[mailgun] Stale Timestamp: this may be an attack');
- console.error('[mailgun] However, this is most likely your fault\n');
- console.error('[mailgun] run `ntpdate ntp.ubuntu.com` and check your system clock\n');
- console.error('[mailgun] System Time: ' + new Date().toString());
- console.error('[mailgun] Mailgun Time: ' + new Date(adjustedTimestamp).toString(), timestamp);
- console.error('[mailgun] Delta: ' + (Date.now() - adjustedTimestamp));
- return false;
- }
-
- if (mailgunTokens[token]) {
- console.error('[mailgun] Replay Attack');
- return false;
- }
-
- mailgunTokens[token] = true;
-
- setTimeout(function () {
- delete mailgunTokens[token];
- }, mailgunExpirey + (5 * 1000));
-
- actual = crypto.createHmac(mailgunHashType, apiKey)
- .update(new Buffer(timestamp + token, 'utf8'))
- .digest(mailgunSignatureEncoding)
- ;
- return scmp(signature, actual);
- }
-
- function mailgunMail(/*opts*/) {
- return apiDeps.Promise.resolve(req.getSiteMailer());
- }
- function getResponseList() {
- return apiDeps.Promise.resolve(req.GetResponse);
- }
-
- // Twilio Parameters are often 26 long
- var bodyParserTwilio = require('body-parser').urlencoded({ limit: '4kb', parameterLimit: 100, extended: false });
- // Mailgun has something like 50 parameters
- var bodyParserMailgun = require('body-parser').urlencoded({ limit: '1024kb', parameterLimit: 500, extended: false });
- function bodyMultiParserMailgun (req, res, next) {
- var multiparty = require('multiparty');
- var form = new multiparty.Form();
-
- form.parse(req, function (err, fields/*, files*/) {
- if (err) {
- console.error('Error');
- console.error(err);
- res.end("Couldn't parse form");
- return;
- }
-
- var body;
- req.body = req.body || {};
- Object.keys(fields).forEach(function (key) {
- // TODO what if there were two of something?
- // (even though there won't be)
- req.body[key] = fields[key][0];
- });
- body = req.body;
-
- next();
- });
- }
-
- function daplieTel() {
- return twilioTel().then(function (twilio) {
- function sms(opts) {
- // opts = { to, from, body }
- return new apiDeps.Promise(function (resolve, reject) {
- twilio.sendSms(opts, function (err, resp) {
- if (err) {
- reject(err);
- return;
- }
-
- resolve(resp);
- });
- });
- }
-
- return {
- sms: sms
- , mms: function () { throw new Error('MMS Not Implemented'); }
- };
- });
- }
-
- var caps = {
- //
- // Capabilities for APIs
- //
- 'email@daplie.com': mailgunMail // whichever mailer
- , 'mailer@daplie.com': mailgunMail // whichever mailer
- , 'mailgun@daplie.com': mailgunMail // specifically mailgun
- , 'tel@daplie.com': daplieTel // whichever telephony service
- , 'twilio@daplie.com': twilioTel // specifically twilio
- , 'com.daplie.tel.twilio': twilioTel // deprecated alias
-
- , 'getresponse@daplie.com': getResponseList
- //
- // Webhook Parsers
- //
- //, 'mailgun.urlencoded@daplie.com': function (req, res, next) { ... }
- , 'mailgun.parsers@daplie.com': function (req, res, next) {
- var chunks = [];
-
- req.on('data', function (chunk) {
- chunks.push(chunk);
- });
- req.on('end', function () {
- });
-
- function verify() {
- var body = req.body;
- var mailconf = siteConfig['mailgun.org'];
-
- if (!body.timestamp) {
- console.log('mailgun parser req.headers');
- console.log(req.headers);
- chunks.forEach(function (datum) {
- console.log('Length:', datum.length);
- //console.log(datum.toString('utf8'));
- });
- console.log('weird body');
- console.log(body);
- }
-
- if (!validateMailgun(mailconf.apiKey, body.timestamp, body.token, body.signature)) {
- console.error('Request came, but not from Mailgun');
- console.error(req.url);
- console.error(req.headers);
- res.send({ error: { message: 'Invalid signature. Are you even Mailgun?' } });
- return;
- }
-
- next();
- }
-
- if (/urlencoded/.test(req.headers['content-type'])) {
- console.log('urlencoded');
- bodyParserMailgun(req, res, verify);
- }
- else if (/multipart/.test(req.headers['content-type'])) {
- console.log('multipart');
- bodyMultiParserMailgun(req, res, verify);
- }
- else {
- console.log('no parser');
- next();
- }
- }
- , 'twilio.urlencoded@daplie.com': function (req, res, next) {
- // TODO null for res and Promise instead of next?
- return bodyParserTwilio(req, res, function () {
- var signature = req.headers['x-twilio-signature'];
- var auth = siteConfig['twilio.com'].live.auth;
- var fullUrl = 'https://' + req.headers.host + req._walnutOriginalUrl;
- var validSig = Twilio.validateRequest(auth, signature, fullUrl, req.body);
- /*
- console.log('Twilio Signature Check');
- console.log('auth', auth);
- console.log('sig', signature);
- console.log('fullUrl', fullUrl);
- console.log(req.body);
- console.log('valid', validSig);
- */
- if (!validSig) {
- res.statusCode = 401;
- res.setHeader('Content-Type', 'text/xml');
- res.end('Invalid signature. Are you even Twilio?');
- return;
- }
- // TODO session via db req.body.CallId req.body.smsId
- next();
- });
- }
- };
- req.getSiteCapability = function (capname, opts, b, c) {
- if (caps[capname]) {
- return caps[capname](opts, b, c);
- }
- if (siteConfig[capname]) {
- var service = siteConfig[capname].service || siteConfig[capname];
- if (caps[service]) {
- return caps[service](opts, b, c);
- }
- }
- return apiDeps.Promise.reject(
- new Error("['" + req.clientApiUri + '/' + pkgId + "'] "
- + "capability '" + capname + "' not implemented")
- );
- };
-
- req._walnutOriginalUrl = req.url;
- // "/path/api/com.example/hello".replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/') => '/hello'
- req.url = req.url.replace(/\/api\//, '').replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/');
- next();
+ return loadRestHelperApi(myConf, clientUrih, pkg, pkgId, pkgPath).then(function (stuff) {
+ return loadRestHelperAssets(myConf, clientUrih, pkg, pkgId, pkgPath).then(function (assetsHandler) {
+ stuff.assetsHandler = assetsHandler;
+ return stuff;
+ }, function (err) {
+ console.error('[lib/api.js] no assets handler:');
+ console.error(err);
+ return stuff;
});
});
- myApp.use('/public', function preHandler(req, res, next) {
- // TODO authenticate or use guest user
- req.isPublic = true;
- next();
- });
- myApp.use('/accounts/:accountId', accountRequiredById);
- myApp.use('/acl', accountRequired);
-
- //
- // TODO handle /accounts/:accountId
- //
- return PromiseA.resolve(require(pkgPath).create({
- etcpath: xconfx.etcpath
- }/*pkgConf*/, pkgDeps/*pkgDeps*/, myApp/*myApp*/)).then(function (handler) {
-
- myApp.use('/', function postHandler(req, res, next) {
- req.url = req._walnutOriginalUrl;
- next();
- });
-
- localCache.pkgs[pkgId] = { pkgId: pkgId, pkg: pkg, handler: handler || myApp, createdAt: Date.now() };
- pkgLinks.forEach(function (pkgLink) {
- localCache.pkgs[pkgLink] = localCache.pkgs[pkgId];
- });
- return localCache.pkgs[pkgId];
- });
});
});
}
@@ -777,31 +1049,45 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
return function (req, res, next) {
cors(req, res, function () {
- if (xconfx.debug) { console.log('[api.js] post cors'); }
+ //if (xconfx.debug) { console.log('[api.js] after cors'); }
// Canonical client names
// example.com should use api.example.com/api for all requests
// sub.example.com/api should resolve to sub.example.com
- // example.com/subpath/api should resolve to example.com#subapp
- // sub.example.com/subpath/api should resolve to sub.example.com#subapp
- var clientUrih = req.hostname.replace(/^api\./, '') + req.url.replace(/\/api\/.*/, '/').replace(/\/+/g, '#').replace(/#$/, '');
- var clientApiUri = req.hostname + req.url.replace(/\/api\/.*/, '/').replace(/\/$/, '');
+ // example.com/subapp/api should resolve to example.com#subapp
+ // sub.example.com/subapp/api should resolve to sub.example.com#subapp
+ var appUri = req.hostname.replace(/^(api|assets)\./, '') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, '');
+ var clientUrih = appUri.replace(/\/+/g, '#').replace(/#$/, '');
+ var clientApiUri = req.hostname.replace(/^(api|assets)\./, 'api.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, '');
+ var clientAssetsUri = req.hostname.replace(/^(api|assets)\./, 'assets.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, '');
+ //var clientAssetsUri = req.hostname.replace(/^(api|assets)\./, 'api.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, '');
// Canonical package names
// '/api/com.daplie.hello/hello' should resolve to 'com.daplie.hello'
// '/subapp/api/com.daplie.hello/hello' should also 'com.daplie.hello'
// '/subapp/api/com.daplie.hello/' may exist... must be a small api
- var pkgId = req.url.replace(/.*\/api\//, '').replace(/^\//, '').replace(/\/.*/, '');
+ var pkgId = req.url.replace(/.*\/(api|assets)\//, '').replace(/^\//, '').replace(/\/.*/, '');
var now = Date.now();
var hasBeenHandled = false;
- // Existing (Deprecated)
+ Object.defineProperty(req, 'clientUrl', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: (req.headers.referer || ('https://' + appUri)).replace(/\/$/, '').replace(/\?.*/, '')
+ });
Object.defineProperty(req, 'apiUrlPrefix', {
enumerable: true
, configurable: false
, writable: false
, value: 'https://' + clientApiUri + '/api/' + pkgId
});
- Object.defineProperty(req, 'experienceId', {
+ Object.defineProperty(req, 'assetsUrlPrefix', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: 'https://' + clientAssetsUri + '/assets/' + pkgId
+ });
+ Object.defineProperty(req, 'experienceId' /*deprecated*/, {
enumerable: true
, configurable: false
, writable: false
@@ -813,6 +1099,12 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
, writable: false
, value: clientApiUri
});
+ Object.defineProperty(req, 'clientAssetsUri', {
+ enumerable: true
+ , configurable: false
+ , writable: false
+ , value: clientAssetsUri
+ });
Object.defineProperty(req, 'apiId', {
enumerable: true
, configurable: false
@@ -820,7 +1112,6 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
, value: pkgId
});
- // New
Object.defineProperty(req, 'clientUrih', {
enumerable: true
, configurable: false
@@ -838,37 +1129,61 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) {
});
// TODO cache permission (although the FS is already cached, NBD)
- var promise = isThisClientAllowedToUseThisPkg(xconfx, clientUrih, pkgId).then(function (yes) {
+ var promise = isThisClientAllowedToUseThisPkg(req, xconfx, clientUrih, pkgId).then(function (yes) {
+ //if (xconfx.debug) { console.log('[api.js] azp is allowed?', yes); }
if (!yes) {
notConfigured(req, res);
return null;
}
+ function handleWithHandler() {
+ if (/\/assets\//.test(req.url) || /(^|\.)assets\./.test(req.hostname)) {
+ if (localCache.assets[pkgId]) {
+ if ('function' !== typeof localCache.assets[pkgId].handler) { console.log('localCache.assets[pkgId]'); console.log(localCache.assets[pkgId]); }
+ localCache.assets[pkgId].handler(req, res, next);
+ } else {
+ next();
+ return true;
+ }
+ } else {
+ localCache.rests[pkgId].handler(req, res, next);
+ }
+ }
+
if (localCache.rests[pkgId]) {
- localCache.rests[pkgId].handler(req, res, next);
+ if (handleWithHandler()) {
+ return;
+ }
hasBeenHandled = true;
if (now - localCache.rests[pkgId].createdAt > staleAfter) {
localCache.rests[pkgId] = null;
+ localCache.assets[pkgId] = null;
}
}
if (!localCache.rests[pkgId]) {
//return doesThisPkgExist
+ //if (xconfx.debug) { console.log('[api.js] before rest handler'); }
return loadRestHandler(xconfx, clientUrih, pkgId).then(function (myHandler) {
if (!myHandler) {
+ //if (xconfx.debug) { console.log('[api.js] not configured'); }
notConfigured(req, res);
return;
}
localCache.rests[pkgId] = { handler: myHandler.handler, createdAt: now };
+ localCache.assets[pkgId] = { handler: myHandler.assetsHandler, createdAt: now };
if (!hasBeenHandled) {
- myHandler.handler(req, res, next);
+ if (handleWithHandler()) {
+ return;
+ }
}
});
}
});
+
rejectableRequest(req, res, promise, "[walnut@daplie.com] load api package");
});
};
diff --git a/lib/common.js b/lib/common.js
index 4c74c4a..701c0a3 100644
--- a/lib/common.js
+++ b/lib/common.js
@@ -1,20 +1,21 @@
'use strict';
-module.exports.rejectableRequest = function rejectableRequest(req, res, promise, msg) {
+function rejectableRequest(req, res, promise, msg) {
return promise.error(function (err) {
res.error(err);
}).catch(function (err) {
console.error('[ERROR] \'' + msg + '\'');
- console.error(err.message);
- console.error(err.stack);
+ // The stack contains the message as well, so no need to log the message when we log the stack
+ console.error(err.stack || err.message || JSON.stringify(err));
res.error(err);
});
-};
+}
+module.exports.rejectableRequest = rejectableRequest;
module.exports.promisableRequest =
module.exports.promiseRequest = function promiseRequest(req, res, promise, msg) {
- return promise.then(function (result) {
+ promise = promise.then(function (result) {
if (result._cache) {
res.setHeader('Cache-Control', 'public, max-age=' + (result._cache / 1000));
res.setHeader('Expires', new Date(Date.now() + result._cache).toUTCString());
@@ -26,13 +27,7 @@ module.exports.promiseRequest = function promiseRequest(req, res, promise, msg)
result = result._value;
}
res.send(result);
- }).error(function (err) {
- res.error(err);
- }).catch(function (err) {
- console.error('[ERROR] \'' + msg + '\'');
- console.error(err.message);
- console.error(err.stack);
-
- res.error(err);
});
+
+ return rejectableRequest(req, res, promise, msg);
};
diff --git a/lib/main.js b/lib/main.js
index c3d3515..8fd8eff 100644
--- a/lib/main.js
+++ b/lib/main.js
@@ -1,6 +1,6 @@
'use strict';
-module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi) {
+module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi, errorIfAssets) {
var PromiseA = require('bluebird');
var path = require('path');
var fs = PromiseA.promisifyAll(require('fs'));
@@ -293,10 +293,27 @@ module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi
// TODO handle assets.example.com/sub/assets/com.example.xyz/
app.use('/api', require('connect-send-error').error());
+ app.use('/assets', require('connect-send-error').error());
app.use('/', function (req, res, next) {
- // If this doesn't look like an API we can move along
- if (!/\/api(\/|$)/.test(req.url)) {
- // /^api\./.test(req.hostname) &&
+ // If this doesn't look like an API or assets we can move along
+
+ /*
+ console.log('.');
+ console.log('[main.js] req.url, req.hostname');
+ console.log(req.url);
+ console.log(req.hostname);
+ console.log('.');
+ */
+
+ if (!/\/(api|assets)(\/|$)/.test(req.url)) {
+ //console.log('[main.js] api|assets');
+ next();
+ return;
+ }
+
+ // keep https://assets.example.com/assets but skip https://example.com/assets
+ if (/\/assets(\/|$)/.test(req.url) && !/(^|\.)(api|assets)(\.)/.test(req.hostname) && !/^[0-9\.]+$/.test(req.hostname)) {
+ //console.log('[main.js] skip');
next();
return;
}
@@ -325,6 +342,7 @@ module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi
return;
});
app.use('/', errorIfApi);
+ app.use('/', errorIfAssets);
app.use('/', serveStatic);
app.use('/', serveApps);
diff --git a/lib/oauth3.js b/lib/oauth3.js
new file mode 100644
index 0000000..63f1427
--- /dev/null
+++ b/lib/oauth3.js
@@ -0,0 +1,306 @@
+'use strict';
+
+var PromiseA = require('bluebird');
+
+function generateRescope(req, Models, decoded, fullPpid, ppid) {
+ return function (/*sub*/) {
+ // TODO: this function is supposed to convert PPIDs of different parties to some account
+ // ID that allows application to keep track of permisions and what-not.
+ console.log('[rescope] Attempting ', fullPpid);
+ return Models.IssuerOauth3OrgGrants.find({ azpSub: fullPpid }).then(function (results) {
+ if (results[0]) {
+ console.log('[rescope] lukcy duck: got it on the 1st try');
+ return PromiseA.resolve(results);
+ }
+
+ // XXX BUG XXX
+ // should be able to distinguish between own ids and 3rd party via @whatever.com
+ return Models.IssuerOauth3OrgGrants.find({ azpSub: ppid });
+ }).then(function (results) {
+ var result = results[0];
+
+ if (!result || !result.sub || !decoded.iss) {
+ // XXX BUG XXX TODO swap this external ppid for an internal (and ask user to link with existing profile)
+ //req.oauth3.accountIdx = fullPpid;
+ throw new Error("internal / external ID swapping not yet implemented. TODO: "
+ + "No profile found with that credential. Would you like to create a new profile or link to an existing profile?");
+ }
+
+ // XXX BUG XXX need to pass own url in to use as issuer for own tokens
+ req.oauth3.accountIdx = result.sub + '@' + decoded.iss;
+
+ console.log('[rescope] result:');
+ console.log(results);
+ console.log(req.oauth3.accountIdx);
+
+ return PromiseA.resolve(req.oauth3.accountIdx);
+ });
+ };
+}
+
+function extractAccessToken(req) {
+ var token = null;
+ var parts;
+ var scheme;
+ var credentials;
+
+ if (req.headers && req.headers.authorization) {
+ // Works for all of Authorization: Bearer {{ token }}, Token {{ token }}, JWT {{ token }}
+ parts = req.headers.authorization.split(' ');
+
+ if (parts.length !== 2) {
+ return PromiseA.reject(new Error("malformed Authorization header"));
+ }
+
+ scheme = parts[0];
+ credentials = parts[1];
+
+ if (-1 !== ['token', 'bearer'].indexOf(scheme.toLowerCase())) {
+ token = credentials;
+ }
+ }
+
+ if (req.body && req.body.access_token) {
+ if (token) { PromiseA.reject(new Error("token exists in header and body")); }
+ token = req.body.access_token;
+ }
+
+ // TODO disallow query with req.method === 'GET'
+ // NOTE: the case of DDNS on routers requires a GET and access_token
+ // (cookies should be used for protected static assets)
+ if (req.query && req.query.access_token) {
+ if (token) { PromiseA.reject(new Error("token already exists in either header or body and also in query")); }
+ token = req.query.access_token;
+ }
+
+ /*
+ err = new Error(challenge());
+ err.code = 'E_BEARER_REALM';
+
+ if (!token) { return PromiseA.reject(err); }
+ */
+
+ return PromiseA.resolve(token);
+}
+
+function verifyToken(token) {
+ var jwt = require('jsonwebtoken');
+ var decoded;
+
+ if (!token) {
+ return PromiseA.reject({
+ message: 'no token provided'
+ , code: 'E_NO_TOKEN'
+ , url: 'https://oauth3.org/docs/errors#E_NO_TOKEN'
+ });
+ }
+
+ try {
+ decoded = jwt.decode(token, {complete: true});
+ } catch (e) {}
+ if (!decoded) {
+ return PromiseA.reject({
+ message: 'provided token not a JSON Web Token'
+ , code: 'E_NOT_JWT'
+ , url: 'https://oauth3.org/docs/errors#E_NOT_JWT'
+ });
+ }
+
+ var sub = decoded.payload.sub || decoded.payload.ppid || decoded.payload.appScopedId;
+ if (!sub) {
+ return PromiseA.reject({
+ message: 'token missing sub'
+ , code: 'E_MISSING_SUB'
+ , url: 'https://oauth3.org/docs/errors#E_MISSING_SUB'
+ });
+ }
+ var kid = decoded.header.kid || decoded.payload.kid;
+ if (!kid) {
+ return PromiseA.reject({
+ message: 'token missing kid'
+ , code: 'E_MISSING_KID'
+ , url: 'https://oauth3.org/docs/errors#E_MISSING_KID'
+ });
+ }
+ if (!decoded.payload.iss) {
+ return PromiseA.reject({
+ message: 'token missing iss'
+ , code: 'E_MISSING_ISS'
+ , url: 'https://oauth3.org/docs/errors#E_MISSING_ISS'
+ });
+ }
+
+ var OAUTH3 = require('oauth3.js');
+ OAUTH3._hooks = require('oauth3.js/oauth3.node.storage.js');
+ return OAUTH3.discover(decoded.payload.iss).then(function (directives) {
+ var args = (directives || {}).retrieve_jwk;
+ if (typeof args === 'string') {
+ args = { url: args, method: 'GET' };
+ }
+ if (typeof (args || {}).url !== 'string') {
+ return PromiseA.reject({
+ message: 'token issuer does not support retrieving JWKs'
+ , code: 'E_INVALID_ISS'
+ , url: 'https://oauth3.org/docs/errors#E_INVALID_ISS'
+ });
+ }
+
+ var params = {
+ sub: sub
+ , kid: kid
+ };
+ var url = args.url;
+ var body;
+ Object.keys(params).forEach(function (key) {
+ if (url.indexOf(':'+key) !== -1) {
+ url = url.replace(':'+key, params[key]);
+ delete params[key];
+ }
+ });
+ if (Object.keys(params).length > 0) {
+ if ('GET' === (args.method || 'GET').toUpperCase()) {
+ url += '?' + OAUTH3.query.stringify(params);
+ } else {
+ body = params;
+ }
+ }
+
+ return OAUTH3.request({
+ url: OAUTH3.url.resolve(directives.api, url)
+ , method: args.method
+ , data: body
+ }).catch(function (err) {
+ return PromiseA.reject({
+ message: 'failed to retrieve public key from token issuer'
+ , code: 'E_NO_PUB_KEY'
+ , url: 'https://oauth3.org/docs/errors#E_NO_PUB_KEY'
+ , subErr: err.toString()
+ });
+ });
+ }, function (err) {
+ return PromiseA.reject({
+ message: 'token issuer is not a valid OAuth3 provider'
+ , code: 'E_INVALID_ISS'
+ , url: 'https://oauth3.org/docs/errors#E_INVALID_ISS'
+ , subErr: err.toString()
+ });
+ }).then(function (res) {
+ if (res.data.error) {
+ return PromiseA.reject(res.data.error);
+ }
+ var opts = {};
+ if (Array.isArray(res.data.alg)) {
+ opts.algorithms = res.data.alg;
+ } else if (typeof res.data.alg === 'string') {
+ opts.algorithms = [res.data.alg];
+ }
+
+ try {
+ return jwt.verify(token, require('jwk-to-pem')(res.data), opts);
+ } catch (err) {
+ return PromiseA.reject({
+ message: 'token verification failed'
+ , code: 'E_INVALID_TOKEN'
+ , url: 'https://oauth3.org/docs/errors#E_INVALID_TOKEN'
+ , subErr: err.toString()
+ });
+ }
+ });
+}
+
+function deepFreeze(obj) {
+ Object.keys(obj).forEach(function (key) {
+ if (obj[key] && typeof obj[key] === 'object') {
+ deepFreeze(obj[key]);
+ }
+ });
+ Object.freeze(obj);
+}
+
+function cookieOauth3(Models, req, res, next) {
+ req.oauth3 = {};
+
+ var token = req.cookies.jwt;
+
+ req.oauth3.encodedToken = token;
+ req.oauth3.verifyAsync = function (jwt) {
+ return verifyToken(jwt || token);
+ };
+
+ return verifyToken(token).then(function (decoded) {
+ req.oauth3.token = decoded;
+ if (!decoded) {
+ return null;
+ }
+
+ var ppid = decoded.sub || decoded.ppid || decoded.appScopedId;
+ req.oauth3.ppid = ppid;
+ req.oauth3.accountIdx = ppid+'@'+decoded.iss;
+
+ var hash = require('crypto').createHash('sha256').update(req.oauth3.accountIdx).digest('base64');
+ hash = hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+/g, '');
+ req.oauth3.accountHash = hash;
+
+ req.oauth3.rescope = generateRescope(req, Models, decoded, fullPpid, ppid);
+ }).then(function () {
+ deepFreeze(req.oauth3);
+ //Object.defineProperty(req, 'oauth3', {configurable: false, writable: false});
+ next();
+ }, function (err) {
+ if ('E_NO_TOKEN' === err.code) {
+ next();
+ return;
+ }
+ console.error('[walnut] cookie lib/oauth3 error:');
+ console.error(err);
+ res.send(err);
+ });
+}
+
+function attachOauth3(Models, req, res, next) {
+ req.oauth3 = {};
+
+ extractAccessToken(req).then(function (token) {
+ req.oauth3.encodedToken = token;
+ req.oauth3.verifyAsync = function (jwt) {
+ return verifyToken(jwt || token);
+ };
+
+ if (!token) {
+ return null;
+ }
+ return verifyToken(token);
+ }).then(function (decoded) {
+ req.oauth3.token = decoded;
+ if (!decoded) {
+ return null;
+ }
+
+ var ppid = decoded.sub || decoded.ppid || decoded.appScopedId;
+ var fullPpid = ppid+'@'+decoded.iss;
+ req.oauth3.ppid = ppid;
+
+ // TODO we can anonymize the relationship between our user as the other service's user
+ // in our own database by hashing the remote service's ppid and using that as the lookup
+ var hash = require('crypto').createHash('sha256').update(fullPpid).digest('base64');
+ hash = hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+/g, '');
+ req.oauth3.accountHash = hash;
+
+ req.oauth3.rescope = generateRescope(req, Models, decoded, fullPpid, ppid);
+
+ console.log('############### assigned req.oauth3:');
+ console.log(req.oauth3);
+ }).then(function () {
+ //deepFreeze(req.oauth3);
+ //Object.defineProperty(req, 'oauth3', {configurable: false, writable: false});
+ next();
+ }, function (err) {
+ console.error('[walnut] JWT lib/oauth3 error:');
+ console.error(err);
+ res.send(err);
+ });
+}
+
+module.exports.attachOauth3 = attachOauth3;
+module.exports.cookieOauth3 = cookieOauth3;
+module.exports.verifyToken = verifyToken;
diff --git a/lib/package-server-apis.js b/lib/package-server-apis.js
index 945933b..96ae370 100644
--- a/lib/package-server-apis.js
+++ b/lib/package-server-apis.js
@@ -55,19 +55,7 @@ function getApi(conf, pkgConf, pkgDeps, packagedApi) {
packagedApi._api = require('express-lazy')();
packagedApi._api_app = myApp;
- //require('./oauth3-auth').inject(conf, packagedApi._api, pkgConf, pkgDeps);
- pkgDeps.getOauth3Controllers =
- packagedApi._getOauth3Controllers = require('oauthcommon/example-oauthmodels').create(conf).getControllers;
- require('oauthcommon').inject(packagedApi._getOauth3Controllers, packagedApi._api, pkgConf, pkgDeps);
-
- // DEBUG
- //
- /*
- packagedApi._api.use('/', function (req, res, next) {
- console.log('[DEBUG pkgApiApp]', req.method, req.hostname, req.url);
- next();
- });
- //*/
+ packagedApi._api.use('/', require('./oauth3').attachOauth3);
// TODO fix backwards compat
diff --git a/lib/worker.js b/lib/worker.js
index ce7d795..abe224a 100644
--- a/lib/worker.js
+++ b/lib/worker.js
@@ -150,6 +150,21 @@ module.exports.create = function (webserver, xconfx, state) {
models: models
// TODO don't let packages use this directly
, Promise: PromiseA
+ , dns: PromiseA.promisifyAll(require('dns'))
+ , crypto: PromiseA.promisifyAll(require('crypto'))
+ , fs: PromiseA.promisifyAll(require('fs'))
+ , path: require('path')
+ , validate: {
+ isEmail: function (email) {
+ return /@/.test(email) && !/\s+/.test(email);
+ }
+ , email: function (email) {
+ if (apiDeps.validate.isEmail(email)) {
+ return null;
+ }
+ return new Error('invalid email address');
+ }
+ }
};
var apiFactories = {
memstoreFactory: { create: scopeMemstore }
@@ -180,7 +195,7 @@ module.exports.create = function (webserver, xconfx, state) {
function setupMain() {
if (xconfx.debug) { console.log('[main] setup'); }
mainApp = express();
- require('./main').create(mainApp, xconfx, apiFactories, apiDeps, errorIfApi).then(function () {
+ require('./main').create(mainApp, xconfx, apiFactories, apiDeps, errorIfApi, errorIfAssets).then(function () {
if (xconfx.debug) { console.log('[main] ready'); }
// TODO process.send({});
});
@@ -225,6 +240,24 @@ module.exports.create = function (webserver, xconfx, state) {
next();
}
+ function errorIfNotAssets(req, res, next) {
+ var hostname = req.hostname || req.headers.host;
+
+ if (!/^assets\.[a-z0-9\-]+/.test(hostname)) {
+ res.send({ error:
+ { message: "['" + hostname + req.url + "'] protected asset access is restricted to proper 'asset'-prefixed lowercase subdomains."
+ + " The HTTP 'Host' header must exist and must begin with 'assets.' as in 'assets.example.com'."
+ + " For development you may test with assets.localhost.daplie.me (or any domain by modifying your /etc/hosts)"
+ , code: 'E_NOT_API'
+ , _hostname: hostname
+ }
+ });
+ return;
+ }
+
+ next();
+ }
+
function errorIfApi(req, res, next) {
if (!/^api\./.test(req.headers.host)) {
next();
@@ -240,7 +273,25 @@ module.exports.create = function (webserver, xconfx, state) {
return;
}
- res.send({ error: { code: 'E_NO_IMPL', message: "not implemented" } });
+ res.send({ error: { code: 'E_NO_IMPL', message: "API not implemented" } });
+ }
+
+ function errorIfAssets(req, res, next) {
+ if (!/^assets\./.test(req.headers.host)) {
+ next();
+ return;
+ }
+
+ // has api. hostname prefix
+
+ // doesn't have /api url prefix
+ if (!/^\/assets\//.test(req.url)) {
+ console.log('[walnut/worker assets] req.url', req.url);
+ res.send({ error: { message: "missing /assets/ url prefix" } });
+ return;
+ }
+
+ res.send({ error: { code: 'E_NO_IMPL', message: "assets handler not implemented" } });
}
app.disable('x-powered-by');
@@ -258,8 +309,11 @@ module.exports.create = function (webserver, xconfx, state) {
}));
app.use('/api', recase);
+ var cookieParser = require('cookie-parser'); // signing is done in JWT
+
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.use('/api', errorIfNotApi);
+ app.use('/assets', /*errorIfNotAssets,*/ cookieParser()); // serializer { path: '/assets', httpOnly: true, sameSite: true/*, domain: assets.example.com*/ }
app.use('/', function (req, res) {
if (!(req.encrypted || req.secure)) {
// did not come from https
diff --git a/package.json b/package.json
index 348535a..7f8f644 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,8 @@
"express": "4.x",
"express-lazy": "^1.1.1",
"express-session": "^1.11.3",
+ "jsonwebtoken": "^7.4.1",
+ "jwk-to-pem": "^1.2.6",
"mailchimp-api-v3": "^1.7.0",
"mandrill-api": "^1.0.45",
"masterquest-sqlite3": "git+https://git.daplie.com/node/masterquest-sqlite3.git",
@@ -59,7 +61,7 @@
"multiparty": "^4.1.3",
"nodemailer": "^1.4.0",
"nodemailer-mailgun-transport": "1.x",
- "oauthcommon": "git+https://git.daplie.com/node/oauthcommon.git",
+ "oauth3.js": "git+https://git.daplie.com/OAuth3/oauth3.js.git",
"request": "^2.81.0",
"serve-static": "1.x",
"sqlite3-cluster": "git+https://git.daplie.com/coolaj86/sqlite3-cluster.git#v2",