'use strict'; module.exports.create = function (xconfx, apiFactories, apiDeps) { var PromiseA = apiDeps.Promise; var mkdirpAsync = PromiseA.promisify(require('mkdirp')); //var express = require('express'); var express = require('express-lazy'); var fs = PromiseA.promisifyAll(require('fs')); var path = require('path'); var localCache = { rests: {}, pkgs: {} }; var promisableRequest = require('./common').promisableRequest; var rejectableRequest = require('./common').rejectableRequest; var crypto = require('crypto'); // TODO xconfx.apispath xconfx.restPath = path.join(__dirname, '..', '..', 'packages', 'rest'); xconfx.apiPath = path.join(__dirname, '..', '..', 'packages', 'api'); xconfx.appApiGrantsPath = path.join(__dirname, '..', '..', 'packages', 'client-api-grants'); xconfx.appConfigPath = path.join(__dirname, '..', '..', 'var'); function notConfigured(req, res) { var msg = "api package '" + req.pkgId + "' not configured for client uri '" + req.experienceId + "'" + ". To configure it place a new line '" + req.pkgId + "' in the file '/srv/walnut/packages/client-api-grants/" + req.experienceId + "'" ; res.send({ error: { message: msg } }); } /* function isThisPkgInstalled(myConf, pkgId) { } */ function isThisClientAllowedToUseThisPkg(myConf, clientUrih, pkgId) { var appApiGrantsPath = path.join(myConf.appApiGrantsPath, clientUrih); return fs.readFileAsync(appApiGrantsPath, 'utf8').then(function (text) { return text.trim().split(/\n/); }, function (err) { if ('ENOENT' !== err.code) { console.error(err); } return []; }).then(function (apis) { if (apis.some(function (api) { if (api === pkgId) { return true; } })) { return true; } if (clientUrih === ('api.' + xconfx.setupDomain) && 'org.oauth3.consumer' === pkgId) { // fallthrough return true; } else { return null; } }); } function getSitePackageConfig(clientUrih, pkgId) { var siteConfigPath = path.join(xconfx.appConfigPath, clientUrih); return mkdirpAsync(siteConfigPath).then(function () { return fs.readFileAsync(path.join(siteConfigPath, pkgId + '.json'), 'utf8').then(function (text) { return JSON.parse(text); }).then(function (data) { return data; }, function (/*err*/) { return {}; }); }); } function getSiteConfig(clientUrih) { // TODO test if the requesting package has permission to the root-level site config var siteConfigPath = path.join(xconfx.appConfigPath, clientUrih); return mkdirpAsync(siteConfigPath).then(function () { return fs.readFileAsync(path.join(siteConfigPath, 'config.json'), 'utf8').then(function (text) { return JSON.parse(text); }).then(function (data) { return data; }, function (/*err*/) { return {}; }); }); } var modelsCache = {}; function getSiteStore(clientUrih, pkgId, dir) { if (!modelsCache[clientUrih]) { modelsCache[clientUrih] = apiFactories.systemSqlFactory.create({ init: true , dbname: clientUrih // '#' is a valid file name character }); } // DB scopes: // system (global) // experience (per domain) // api (per api) // account (per user account) // client (per 3rd party client) // scope Experience to db // scope Api by table // scope Account and Client by column return modelsCache[clientUrih].then(function (db) { var wrap = require('masterquest-sqlite3'); return wrap.wrap(db, dir).then(function (models) { //modelsCache[clientUrih] = PromiseA.resolve(models); return models; }); }); } function accountRequiredById(req, res, next) { var promise = req.oauth3.verifyAsync().then(function (/*result*/) { var tok = req.oauth3.token; var accountId = req.params.accountId || '__NO_ID_GIVEN__'; var ppid; if (tok.sub && tok.sub.split(/,/g).filter(function (ppid) { return ppid === accountId; }).length) { ppid = accountId; } if (tok.axs && tok.axs.filter(function (acc) { return acc.id === accountId || acc.appScopedId === accountId; }).length) { ppid = accountId; } if (tok.acx && accountId === (tok.acx.appScopedId || tok.acx.id || tok.acx)) { ppid = accountId; } if (!ppid) { return PromiseA.reject(new Error("missing accountId '" + accountId + "' in access token")); } return req.oauth3.rescope(ppid).then(function (accountIdx) { req.oauth3.accountIdx = accountIdx; req.oauth3.ppid = ppid; req.oauth3.accountHash = crypto.createHash('sha1').update(accountIdx).digest('hex'); //console.log('[com.daplie.walnut] accountIdx:', accountIdx); //console.log('[com.daplie.walnut] ppid:', ppid); next(); }); }); rejectableRequest(req, res, promise, "[com.daplie.walnut] attach account by id"); } function accountRequired(req, res, next) { // if this already has auth, great if (req.oauth3.ppid) { next(); return; } // being public does not disallow authentication if (req.isPublic && !req.oauth3.encodedToken) { next(); return; } if (!req.oauth3.encodedToken) { rejectableRequest( req , res , PromiseA.reject(new Error("this secure resource requires an access token")) , "[com.daplie.walnut] required account (not /public)" ); return; } // verify the auth if it's here var promise = req.oauth3.verifyAsync().then(function (/*result*/) { var tok = req.oauth3.token; var ppid; var err; if (tok.sub) { if (tok.sub.split(/,/g).length > 1) { err = new Error("more than one 'sub' specified in token"); return PromiseA.reject(err); } ppid = tok.sub; } else if (tok.axs && tok.axs.length) { if (tok.axs.length > 1) { err = new Error("more than one 'axs' specified in token (also, update to using 'sub' instead)"); return PromiseA.reject(err); } ppid = tok.axs[0].appScopedId || tok.axs[0].id; } else if (tok.acx) { ppid = tok.acx.appScopedId || tok.acx.id || tok.acx; } if (!ppid) { return PromiseA.reject(new Error("could not determine accountId from access token")); } return req.oauth3.rescope(ppid).then(function (accountIdx) { req.oauth3.accountIdx = accountIdx; req.oauth3.ppid = ppid; req.oauth3.accountHash = crypto.createHash('sha1').update(accountIdx).digest('hex'); next(); }); }); rejectableRequest(req, res, promise, "[com.daplie.walnut] required account (not /public)"); } function loadRestHelper(myConf, clientUrih, pkgId) { var pkgPath = path.join(myConf.restPath, pkgId); var pkgLinks = []; pkgLinks.push(pkgId); // TODO allow recursion, but catch cycles return fs.lstatAsync(pkgPath).then(function (stat) { if (!stat.isFile()) { return; } return fs.readFileAsync(pkgPath, 'utf8').then(function (text) { pkgId = text.trim(); pkgPath = path.join(myConf.restPath, pkgId); }); }, function () { // ignore error return; }).then(function () { // 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, 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(); }; }; myApp.use('/', require('./oauth3').attachOauth3); // TODO delete these caches when config changes var _stripe; var _stripe_test; var _mandrill; var _mailchimp; var _twilio; 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; } }); 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()); } // 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 // // 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); } 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(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]; }); }); }); } // Read packages/apis/sub.sld.tld (forward dns) to find list of apis as tld.sld.sub (reverse dns) // TODO packages/allowed_apis/sub.sld.tld (?) // TODO auto-register org.oauth3.consumer for primaryDomain (and all sites?) function loadRestHandler(myConf, clientUrih, pkgId) { return PromiseA.resolve().then(function () { if (!localCache.pkgs[pkgId]) { return loadRestHelper(myConf, clientUrih, pkgId); } return localCache.pkgs[pkgId]; // TODO expire require cache /* if (Date.now() - localCache.pkgs[pkgId].createdAt < (5 * 60 * 1000)) { return; } */ }, function (/*err*/) { // TODO what kind of errors might we want to handle? return null; }).then(function (restPkg) { return restPkg; }); } var CORS = require('connect-cors'); var cors = CORS({ credentials: true, headers: [ 'X-Requested-With' , 'X-HTTP-Method-Override' , 'Content-Type' , 'Accept' , 'Authorization' ], methods: [ "GET", "POST", "PATCH", "PUT", "DELETE" ] }); var staleAfter = (5 * 60 * 1000); return function (req, res, next) { cors(req, res, function () { if (xconfx.debug) { console.log('[api.js] post 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/subapp/api should resolve to example.com#subapp // sub.example.com/subapp/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(/\/$/, ''); // 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 now = Date.now(); var hasBeenHandled = false; // Existing (Deprecated) Object.defineProperty(req, 'apiUrlPrefix', { enumerable: true , configurable: false , writable: false , value: 'https://' + clientApiUri + '/api/' + pkgId }); Object.defineProperty(req, 'experienceId', { enumerable: true , configurable: false , writable: false , value: clientUrih }); Object.defineProperty(req, 'clientApiUri', { enumerable: true , configurable: false , writable: false , value: clientApiUri }); Object.defineProperty(req, 'apiId', { enumerable: true , configurable: false , writable: false , value: pkgId }); // New Object.defineProperty(req, 'clientUrih', { enumerable: true , configurable: false , writable: false // TODO this identifier may need to be non-deterministic as to transfer if a domain name changes but is still the "same" app // (i.e. a company name change. maybe auto vs manual register - just like oauth3?) // NOTE: probably best to alias the name logically , value: clientUrih }); Object.defineProperty(req, 'pkgId', { enumerable: true , configurable: false , writable: false , value: pkgId }); // TODO cache permission (although the FS is already cached, NBD) var promise = isThisClientAllowedToUseThisPkg(xconfx, clientUrih, pkgId).then(function (yes) { if (!yes) { notConfigured(req, res); return null; } if (localCache.rests[pkgId]) { localCache.rests[pkgId].handler(req, res, next); hasBeenHandled = true; if (now - localCache.rests[pkgId].createdAt > staleAfter) { localCache.rests[pkgId] = null; } } if (!localCache.rests[pkgId]) { //return doesThisPkgExist return loadRestHandler(xconfx, clientUrih, pkgId).then(function (myHandler) { if (!myHandler) { notConfigured(req, res); return; } localCache.rests[pkgId] = { handler: myHandler.handler, createdAt: now }; if (!hasBeenHandled) { myHandler.handler(req, res, next); } }); } }); rejectableRequest(req, res, promise, "[com.daplie.walnut] load api package"); }); }; };